feat(#87): implement cross-instance identity linking for federation
Implements FED-004: Cross-Instance Identity Linking, building on the foundation from FED-001, FED-002, and FED-003. New Services: - IdentityLinkingService: Handles identity verification and mapping with signature validation and OIDC token verification - IdentityResolutionService: Resolves identities between local and remote instances with support for bulk operations New API Endpoints (IdentityLinkingController): - POST /api/v1/federation/identity/verify - Verify remote identity - POST /api/v1/federation/identity/resolve - Resolve remote to local user - POST /api/v1/federation/identity/bulk-resolve - Bulk resolution - GET /api/v1/federation/identity/me - Get current user's identities - POST /api/v1/federation/identity/link - Create identity mapping - PATCH /api/v1/federation/identity/:id - Update mapping - DELETE /api/v1/federation/identity/:id - Revoke mapping - GET /api/v1/federation/identity/:id/validate - Validate mapping Security Features: - Signature verification using remote instance public keys - OIDC token validation before creating mappings - Timestamp validation to prevent replay attacks - Workspace isolation via authentication guards - Comprehensive audit logging for all identity operations Enhancements: - Added SignatureService.verifyMessage() for remote signature verification - Added FederationService.getConnectionByRemoteInstanceId() - Extended FederationAuditService with identity logging methods - Created comprehensive DTOs with class-validator decorators Testing: - 38 new tests (19 service + 7 resolution + 12 controller) - All 132 federation tests passing - TypeScript compilation passing with no errors - High test coverage achieved (>85% requirement exceeded) Technical Details: - Leverages existing FederatedIdentity model from FED-003 - Uses RSA SHA-256 signatures for cryptographic verification - Supports one identity mapping per remote instance per user - Resolution service optimized for read-heavy operations - Built following TDD principles (Red-Green-Refactor) Closes #87 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
151
apps/api/src/federation/identity-resolution.service.spec.ts
Normal file
151
apps/api/src/federation/identity-resolution.service.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Identity Resolution Service Tests
|
||||
*
|
||||
* Tests for resolving identities between local and remote instances.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { IdentityResolutionService } from "./identity-resolution.service";
|
||||
import { IdentityLinkingService } from "./identity-linking.service";
|
||||
import type { FederatedIdentity } from "./types/oidc.types";
|
||||
|
||||
describe("IdentityResolutionService", () => {
|
||||
let service: IdentityResolutionService;
|
||||
let identityLinkingService: IdentityLinkingService;
|
||||
|
||||
const mockIdentity: FederatedIdentity = {
|
||||
id: "identity-id",
|
||||
localUserId: "local-user-id",
|
||||
remoteUserId: "remote-user-id",
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
oidcSubject: "oidc-subject",
|
||||
email: "user@example.com",
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockIdentityLinkingService = {
|
||||
resolveLocalIdentity: vi.fn(),
|
||||
resolveRemoteIdentity: vi.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
IdentityResolutionService,
|
||||
{ provide: IdentityLinkingService, useValue: mockIdentityLinkingService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<IdentityResolutionService>(IdentityResolutionService);
|
||||
identityLinkingService = module.get(IdentityLinkingService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveIdentity", () => {
|
||||
it("should resolve remote identity to local user", async () => {
|
||||
identityLinkingService.resolveLocalIdentity.mockResolvedValue(mockIdentity);
|
||||
|
||||
const result = await service.resolveIdentity("remote-instance-id", "remote-user-id");
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.localUserId).toBe("local-user-id");
|
||||
expect(result.remoteUserId).toBe("remote-user-id");
|
||||
expect(result.email).toBe("user@example.com");
|
||||
expect(identityLinkingService.resolveLocalIdentity).toHaveBeenCalledWith(
|
||||
"remote-instance-id",
|
||||
"remote-user-id"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return not found when mapping does not exist", async () => {
|
||||
identityLinkingService.resolveLocalIdentity.mockResolvedValue(null);
|
||||
|
||||
const result = await service.resolveIdentity("remote-instance-id", "unknown-user-id");
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.localUserId).toBeUndefined();
|
||||
expect(result.remoteUserId).toBe("unknown-user-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reverseResolveIdentity", () => {
|
||||
it("should resolve local user to remote identity", async () => {
|
||||
identityLinkingService.resolveRemoteIdentity.mockResolvedValue(mockIdentity);
|
||||
|
||||
const result = await service.reverseResolveIdentity("local-user-id", "remote-instance-id");
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.remoteUserId).toBe("remote-user-id");
|
||||
expect(result.localUserId).toBe("local-user-id");
|
||||
expect(identityLinkingService.resolveRemoteIdentity).toHaveBeenCalledWith(
|
||||
"local-user-id",
|
||||
"remote-instance-id"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return not found when mapping does not exist", async () => {
|
||||
identityLinkingService.resolveRemoteIdentity.mockResolvedValue(null);
|
||||
|
||||
const result = await service.reverseResolveIdentity("unknown-user-id", "remote-instance-id");
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.remoteUserId).toBeUndefined();
|
||||
expect(result.localUserId).toBe("unknown-user-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkResolveIdentities", () => {
|
||||
it("should resolve multiple remote users to local users", async () => {
|
||||
const mockIdentity2: FederatedIdentity = {
|
||||
...mockIdentity,
|
||||
id: "identity-id-2",
|
||||
localUserId: "local-user-id-2",
|
||||
remoteUserId: "remote-user-id-2",
|
||||
};
|
||||
|
||||
identityLinkingService.resolveLocalIdentity
|
||||
.mockResolvedValueOnce(mockIdentity)
|
||||
.mockResolvedValueOnce(mockIdentity2)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await service.bulkResolveIdentities("remote-instance-id", [
|
||||
"remote-user-id",
|
||||
"remote-user-id-2",
|
||||
"unknown-user-id",
|
||||
]);
|
||||
|
||||
expect(result.mappings["remote-user-id"]).toBe("local-user-id");
|
||||
expect(result.mappings["remote-user-id-2"]).toBe("local-user-id-2");
|
||||
expect(result.notFound).toEqual(["unknown-user-id"]);
|
||||
expect(identityLinkingService.resolveLocalIdentity).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should handle empty array", async () => {
|
||||
const result = await service.bulkResolveIdentities("remote-instance-id", []);
|
||||
|
||||
expect(result.mappings).toEqual({});
|
||||
expect(result.notFound).toEqual([]);
|
||||
expect(identityLinkingService.resolveLocalIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle all not found", async () => {
|
||||
identityLinkingService.resolveLocalIdentity
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await service.bulkResolveIdentities("remote-instance-id", [
|
||||
"unknown-1",
|
||||
"unknown-2",
|
||||
]);
|
||||
|
||||
expect(result.mappings).toEqual({});
|
||||
expect(result.notFound).toEqual(["unknown-1", "unknown-2"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user