diff --git a/server/account/src/__tests__/serviceOperations.test.ts b/server/account/src/__tests__/serviceOperations.test.ts index 7d11125a32d..f4e8a1306f9 100644 --- a/server/account/src/__tests__/serviceOperations.test.ts +++ b/server/account/src/__tests__/serviceOperations.test.ts @@ -40,6 +40,7 @@ import { createIntegration, deleteIntegration, deleteIntegrationSecret, + findPersonBySocialKey, getIntegration, getIntegrationSecret, listIntegrations, @@ -1603,3 +1604,284 @@ describe('upsertSubscription', () => { ) }) }) + +describe('findPersonBySocialKey', () => { + const mockCtx = { + warn: jest.fn(), + error: jest.fn() + } as unknown as MeasureContext + const mockBranding = null + const mockToken = 'test-token' + const callerAccount = 'caller-account' as AccountUuid + const callerWorkspace = 'ws-1' as WorkspaceUuid + const otherWorkspace = 'ws-2' as WorkspaceUuid + + function makeMockDb (options: { + socialId?: { personUuid: string } | null + targetAccountUuid?: string | null + callerWorkspaces?: WorkspaceUuid[] + targetWorkspaces?: WorkspaceUuid[] + }): AccountDB { + const socialId = options.socialId ?? null + const targetAccountUuid = options.targetAccountUuid ?? null + const callerWorkspaces = options.callerWorkspaces ?? [] + const targetWorkspaces = options.targetWorkspaces ?? [] + + const allWorkspaces: Record = { + [callerAccount]: callerWorkspaces, + ...(targetAccountUuid != null ? { [targetAccountUuid]: targetWorkspaces } : {}) + } + + const accountFindOne = jest.fn().mockImplementation(async (query: { uuid: AccountUuid }) => { + if (query.uuid === targetAccountUuid) { + return { uuid: targetAccountUuid } + } + return null + }) + + const getAccountWorkspaces = jest.fn().mockImplementation(async (accountId: AccountUuid) => { + const ws = allWorkspaces[accountId] ?? [] + return ws.map((uuid) => ({ + uuid, + status: { mode: 'active' as any }, + name: 'ws', + url: 'ws', + branding: null, + location: 'local', + region: 'local', + createdBy: callerAccount, + createdOn: 0, + billingAccount: null + } as any)) + }) + + return { + socialId: { + findOne: jest.fn().mockResolvedValue(socialId) + }, + account: { + findOne: accountFindOne + }, + getAccountWorkspaces + } as unknown as AccountDB + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('still rejects empty socialString', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' }, + account: callerAccount + }) + const mockDb = makeMockDb({}) + + await expect( + findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { socialString: '' }) + ).rejects.toThrow(/BadRequest/) + }) + + test('returns undefined when social key is not found', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' }, + account: callerAccount + }) + const mockDb = makeMockDb({ socialId: null }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:missing@example.com' + }) + + expect(result).toBeUndefined() + }) + + test('service token can look up any social key without a workspace check', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'tool' } + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'looked-up-account' }, + targetAccountUuid: 'looked-up-account', + callerWorkspaces: [], + targetWorkspaces: [] + }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com' + }) + + expect(result).toBe('looked-up-account') + expect(mockDb.getAccountWorkspaces).not.toHaveBeenCalled() + }) + + test('admin token can look up any social key without a workspace check', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { admin: 'true' }, + account: callerAccount + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'looked-up-account' }, + targetAccountUuid: 'looked-up-account' + }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com' + }) + + expect(result).toBe('looked-up-account') + expect(mockDb.getAccountWorkspaces).not.toHaveBeenCalled() + }) + + test('user token returns the person when caller and target share an active workspace', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' }, + account: callerAccount + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'looked-up-account' }, + targetAccountUuid: 'looked-up-account', + callerWorkspaces: [callerWorkspace], + targetWorkspaces: [callerWorkspace] + }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com' + }) + + expect(result).toBe('looked-up-account') + expect(mockDb.getAccountWorkspaces).toHaveBeenCalledWith(callerAccount) + expect(mockDb.getAccountWorkspaces).toHaveBeenCalledWith('looked-up-account') + }) + + test('user token returns undefined when caller and target share no active workspace', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' }, + account: callerAccount + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'looked-up-account' }, + targetAccountUuid: 'looked-up-account', + callerWorkspaces: [callerWorkspace], + targetWorkspaces: [otherWorkspace] + }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com' + }) + + expect(result).toBeUndefined() + }) + + test('user token returns undefined when caller has no active workspaces', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' }, + account: callerAccount + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'looked-up-account' }, + targetAccountUuid: 'looked-up-account', + callerWorkspaces: [], + targetWorkspaces: [callerWorkspace] + }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com' + }) + + expect(result).toBeUndefined() + }) + + test('user token returns undefined when the target person has no account', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' }, + account: callerAccount + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'orphan-person' }, + targetAccountUuid: null, + callerWorkspaces: [callerWorkspace] + }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com' + }) + + expect(result).toBeUndefined() + expect(mockDb.getAccountWorkspaces).not.toHaveBeenCalled() + }) + + test('user token with requireAccount=true returns the account uuid when shared workspace exists', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' }, + account: callerAccount + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'looked-up-account' }, + targetAccountUuid: 'looked-up-account', + callerWorkspaces: [callerWorkspace], + targetWorkspaces: [callerWorkspace] + }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com', + requireAccount: true + }) + + expect(result).toBe('looked-up-account') + }) + + test('user token with requireAccount=true returns undefined when no shared workspace', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' }, + account: callerAccount + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'looked-up-account' }, + targetAccountUuid: 'looked-up-account', + callerWorkspaces: [callerWorkspace], + targetWorkspaces: [otherWorkspace] + }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com', + requireAccount: true + }) + + expect(result).toBeUndefined() + }) + + test('user token throws Forbidden when the token has no account claim', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' } + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'looked-up-account' } + }) + + await expect( + findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com' + }) + ).rejects.toThrow(/Forbidden/) + }) + + test('user token does not leak the existence of a non-shared person (no diff vs missing key)', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { authMethod: 'password' }, + account: callerAccount + }) + const mockDb = makeMockDb({ + socialId: { personUuid: 'looked-up-account' }, + targetAccountUuid: 'looked-up-account', + callerWorkspaces: [callerWorkspace], + targetWorkspaces: [otherWorkspace] + }) + + const result = await findPersonBySocialKey(mockCtx, mockDb, mockBranding, mockToken, { + socialString: 'email:alice@example.com' + }) + + expect(result).toBeUndefined() + }) +}) diff --git a/server/account/src/serviceOperations.ts b/server/account/src/serviceOperations.ts index f13af033458..4d67e8acafa 100644 --- a/server/account/src/serviceOperations.ts +++ b/server/account/src/serviceOperations.ts @@ -997,9 +997,8 @@ export async function findPersonBySocialKey ( throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) } - const { extra } = decodeTokenVerbose(ctx, token) - - verifyAllowedServices(['tool', 'workspace', 'aibot', ...integrationServices], extra) + const { extra, account: callerAccount } = decodeTokenVerbose(ctx, token) + const isService = verifyAllowedServices(['tool', 'workspace', 'aibot', ...integrationServices], extra, false) const socialId = await db.socialId.findOne({ key: socialString }) @@ -1007,10 +1006,40 @@ export async function findPersonBySocialKey ( return } - if (params.requireAccount === true) { - const account = await db.account.findOne({ uuid: socialId.personUuid as AccountUuid }) + if (isService || extra?.admin === 'true') { + if (params.requireAccount === true) { + const account = await db.account.findOne({ uuid: socialId.personUuid as AccountUuid }) + + return account?.uuid + } + + return socialId.personUuid + } + + if (callerAccount == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } - return account?.uuid + const targetAccount = await db.account.findOne({ uuid: socialId.personUuid as AccountUuid }) + + if (targetAccount == null) { + return + } + + const [callerWorkspaces, targetWorkspaces] = await Promise.all([ + db.getAccountWorkspaces(callerAccount), + db.getAccountWorkspaces(targetAccount.uuid) + ]) + + const callerActiveWs = new Set(callerWorkspaces.filter((w) => isActiveMode(w.status.mode)).map((w) => w.uuid)) + const shared = targetWorkspaces.some((w) => isActiveMode(w.status.mode) && callerActiveWs.has(w.uuid)) + + if (!shared) { + return + } + + if (params.requireAccount === true) { + return targetAccount.uuid } return socialId.personUuid