diff --git a/apps/discord-bot/src/features/TeamsModule.ts b/apps/discord-bot/src/features/TeamsModule.ts index 5a0218a6c39b..18a0aad60ca0 100644 --- a/apps/discord-bot/src/features/TeamsModule.ts +++ b/apps/discord-bot/src/features/TeamsModule.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import type { DiscordBotConfig } from "../config.ts"; +import { classifyTeamsAgentAccess, IdentityMapStore } from "../identityMap.ts"; import { createMessageWithAttachments, DiscordUploadError } from "../presentation/discordFiles.ts"; import { TeamsSeenStore } from "../store/TeamsSeenStore.ts"; import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; @@ -22,6 +23,7 @@ import { isHumanTeamsMessage, looksLikeGermanProblemReport, mentionsTeamsBot, + resolveTeamsTriggerActor, rootTeamsMessageId, teamsMessageTimestamp, type TeamsMessage, @@ -105,6 +107,7 @@ export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: Dis const discordConfig = yield* DiscordConfig.DiscordConfig; const seenStore = yield* TeamsSeenStore; const links = yield* ThreadLinkStore; + const identityMap = yield* IdentityMapStore; let cachedAccessToken: { readonly token: string; readonly expiresAt: number } | null = null; @@ -463,6 +466,30 @@ export const runTeamsModule = Effect.fn("runTeamsModule")(function* (config: Dis if (trigger === null) return; + const actor = resolveTeamsTriggerActor({ + reason: trigger.reason, + message, + ...(trigger.triggerMessage === undefined ? {} : { triggerMessage: trigger.triggerMessage }), + allowlistedUserIds: channel.internalUserIds, + reactionTriggerTypes: channel.reactionTriggerTypes, + }); + const access = classifyTeamsAgentAccess({ + people: identityMap.list(), + userId: actor.userId, + displayName: actor.displayName, + }); + if (!access.allowed) { + yield* Effect.logWarning("Rejected Teams intake from unmapped identity", { + teamId: channel.teamId, + channelId: channel.channelId, + messageId: message.id, + reason: trigger.reason, + actorUserId: actor.userId, + access: access.reason, + }); + return; + } + const rootMessageId = rootTeamsMessageId(trigger.targetMessage); const rootKey = sourceThreadKey(channel, rootMessageId); if (processedRootKeys.has(rootKey)) return; diff --git a/apps/discord-bot/src/features/TeamsNativeApp.ts b/apps/discord-bot/src/features/TeamsNativeApp.ts index e2bb1fc23bd5..a69854a13d3c 100644 --- a/apps/discord-bot/src/features/TeamsNativeApp.ts +++ b/apps/discord-bot/src/features/TeamsNativeApp.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type { DiscordBotConfig } from "../config.ts"; +import { classifyTeamsAgentAccess, IdentityMapStore, type PersonIdentity } from "../identityMap.ts"; import { derivePendingInteractions } from "../presentation/pendingInteractions.ts"; import { ProjectAliasStore } from "../projectAliases.ts"; import { ThreadLinkStore } from "../store/ThreadLinkStore.ts"; @@ -50,6 +51,25 @@ export function sourceConversationKey(input: TeamsConversationCoordinates): stri return `native/${input.tenantId}/${input.teamId ?? "chat"}/${input.channelId ?? "chat"}/${input.conversationId}`; } +function teamsActorAccess( + people: ReadonlyArray, + from: + | { + readonly id?: string | undefined; + readonly name?: string | undefined; + readonly aadObjectId?: string | undefined; + } + | null + | undefined, +) { + return classifyTeamsAgentAccess({ + people, + aadObjectId: from?.aadObjectId ?? null, + userId: from?.id ?? null, + displayName: from?.name ?? null, + }); +} + function settled(status: string | null | undefined): boolean { return status !== "starting" && status !== "running"; } @@ -220,6 +240,7 @@ export const runTeamsNativeApp = Effect.fn("runTeamsNativeApp")(function* ( config.teamsChannelsPath === undefined ? [] : loadTeamsChannelConfigsFromFileSync(config.teamsChannelsPath); + const identityMap = yield* IdentityMapStore; const t3 = yield* T3Session; const links = yield* ThreadLinkStore; const services = yield* Effect.context(); @@ -236,6 +257,17 @@ export const runTeamsNativeApp = Effect.fn("runTeamsNativeApp")(function* ( app.on("message", async ({ activity, send, reply }) => { await run( Effect.gen(function* () { + const access = teamsActorAccess(identityMap.list(), activity.from); + if (!access.allowed) { + yield* Effect.logWarning("Rejected native Teams interaction from unmapped identity", { + reason: access.reason, + userId: activity.from?.id ?? null, + aadObjectId: activity.from?.aadObjectId ?? null, + }); + yield* Effect.promise(() => reply(access.userMessage)); + return; + } + const location = coordinates(activity); const sourceKey = sourceConversationKey(location); const channel = channelForCoordinates(channels, location); @@ -364,6 +396,10 @@ export const runTeamsNativeApp = Effect.fn("runTeamsNativeApp")(function* ( }); app.on("message.ext.open", async ({ activity }) => { + const access = teamsActorAccess(identityMap.list(), activity.from); + if (!access.allowed) { + return { task: { type: "message" as const, value: access.userMessage } }; + } const location = coordinates(activity); const channel = channelForCoordinates(channels, location); return { @@ -380,6 +416,10 @@ export const runTeamsNativeApp = Effect.fn("runTeamsNativeApp")(function* ( }); app.on("message.ext.submit", async ({ activity }) => { + const access = teamsActorAccess(identityMap.list(), activity.from); + if (!access.allowed) { + return { task: { type: "message" as const, value: access.userMessage } }; + } const data = activity.value.data as | { readonly projectShortName?: unknown; readonly instructions?: unknown } | undefined; diff --git a/apps/discord-bot/src/identityMap.test.ts b/apps/discord-bot/src/identityMap.test.ts index 71f02b2ae561..af705da93f22 100644 --- a/apps/discord-bot/src/identityMap.test.ts +++ b/apps/discord-bot/src/identityMap.test.ts @@ -10,6 +10,7 @@ import { parseIdentityMapDocument, parseSimpleIdentityYaml, classifyDiscordAgentAccess, + classifyTeamsAgentAccess, resolveParticipantIdentity, } from "./identityMap.ts"; @@ -87,6 +88,22 @@ people: expect(people[0]?.github?.id).toBe("12345"); expect(people[0]?.jira?.accountId).toBe("712020:abc"); }); + + it("parses a flat Teams Azure AD object id", () => { + const doc = parseSimpleIdentityYaml(` +people: + "95218063095377920": + name: Patrick Roza + githubLogin: patroza + teamsAadObjectId: "{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}" + teamsUserId: "29:patroza" +`); + const people = parseIdentityMapDocument(doc); + expect(people[0]?.teams).toEqual({ + aadObjectId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + userId: "29:patroza", + }); + }); }); describe("resolveParticipantIdentity", () => { @@ -157,6 +174,52 @@ describe("resolveParticipantIdentity", () => { }); }); +describe("classifyTeamsAgentAccess", () => { + const people = parseIdentityMapDocument({ + people: [ + { + name: "Patrick Roza", + teamsAadObjectId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + teamsUserId: "29:patroza", + }, + ], + }); + + it("fail-closes when the map is empty", () => { + const denied = classifyTeamsAgentAccess({ + people: [], + aadObjectId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + }); + expect(denied.allowed).toBe(false); + if (!denied.allowed) expect(denied.reason).toBe("identity_map_empty"); + }); + + it("allows a mapped Azure AD object id from Graph or Bot Framework", () => { + const fromGraph = classifyTeamsAgentAccess({ + people, + userId: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", + }); + expect(fromGraph.allowed).toBe(true); + + const fromBot = classifyTeamsAgentAccess({ + people, + aadObjectId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + userId: "29:patroza", + }); + expect(fromBot.allowed).toBe(true); + }); + + it("denies unmapped Teams users", () => { + const denied = classifyTeamsAgentAccess({ + people, + aadObjectId: "11111111-2222-3333-4444-555555555555", + userId: "29:stranger", + }); + expect(denied.allowed).toBe(false); + if (!denied.allowed) expect(denied.reason).toBe("unmapped_teams_actor"); + }); +}); + describe("loadIdentityMapFromFileSync", () => { it("loads JSON files", async () => { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-identity-")); diff --git a/apps/discord-bot/src/identityMap.ts b/apps/discord-bot/src/identityMap.ts index 7264cdac75b8..8d8460d561ed 100644 --- a/apps/discord-bot/src/identityMap.ts +++ b/apps/discord-bot/src/identityMap.ts @@ -1,9 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off tryCatchInEffectGen:off /** - * Operator-maintained canonical map across Discord, Jira, and GitHub identities. + * Operator-maintained canonical map across Discord, GitHub, Jira, and Teams identities. * - * Loaded once at bot startup from T3_IDENTITY_MAP_PATH (same delivery path as - * project-aliases: staged secrets share). Absent path → empty map (feature off). + * Loaded from T3_IDENTITY_MAP_PATH (same delivery path as project aliases). + * Absent or empty map denies every Discord and Teams actor. */ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; @@ -35,12 +35,21 @@ export interface JiraIdentityRef { readonly displayName?: string | undefined; } +export interface TeamsIdentityRef { + /** Azure AD object id (GUID). */ + readonly aadObjectId?: string | undefined; + /** Bot Framework user id (`29:…`) when it differs from the Azure AD object id. */ + readonly userId?: string | undefined; + readonly displayName?: string | undefined; +} + export interface PersonIdentity { /** Human display name. */ readonly name: string; readonly discord?: DiscordIdentityRef | undefined; readonly github?: GitHubIdentityRef | undefined; readonly jira?: JiraIdentityRef | undefined; + readonly teams?: TeamsIdentityRef | undefined; } export class IdentityMapLoadError extends Schema.TaggedError()( @@ -81,6 +90,77 @@ function asDiscordSnowflake(value: unknown): string | undefined { return raw; } +const TEAMS_AAD_OBJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +export function normalizeTeamsAadObjectId(value: string | null | undefined): string | null { + if (value === null || value === undefined) return null; + const trimmed = value.trim().replace(/^\{|\}$/gu, ""); + if (!TEAMS_AAD_OBJECT_ID.test(trimmed)) return null; + return trimmed.toLowerCase(); +} + +function parseTeamsRef( + raw: Record, + indexLabel: string, +): TeamsIdentityRef | undefined { + const teamsNested = isRecord(raw.teams) ? raw.teams : undefined; + const explicitAad = + asNonEmptyString(teamsNested?.aadObjectId) ?? + asNonEmptyString(teamsNested?.aad_object_id) ?? + asNonEmptyString(raw.teamsAadObjectId) ?? + asNonEmptyString(raw.teams_aad_object_id); + if (explicitAad !== undefined && normalizeTeamsAadObjectId(explicitAad) === null) { + throw new Error( + `Identity map entry ${indexLabel} teams.aadObjectId must be an Azure AD object id (GUID).`, + ); + } + let aadObjectId = normalizeTeamsAadObjectId(explicitAad) ?? undefined; + const userIdRaw = + asNonEmptyString(teamsNested?.userId) ?? + asNonEmptyString(teamsNested?.user_id) ?? + asNonEmptyString(raw.teamsUserId) ?? + asNonEmptyString(raw.teams_user_id); + let userId = userIdRaw; + if (aadObjectId === undefined && userIdRaw !== undefined) { + const userAsAad = normalizeTeamsAadObjectId(userIdRaw); + if (userAsAad !== null) { + aadObjectId = userAsAad; + userId = undefined; + } + } + const displayName = + asNonEmptyString(teamsNested?.displayName) ?? + asNonEmptyString(raw.teamsDisplayName) ?? + asNonEmptyString(raw.teams_display_name); + if (aadObjectId === undefined && userId === undefined) return undefined; + return { + ...(aadObjectId !== undefined ? { aadObjectId } : {}), + ...(userId !== undefined ? { userId } : {}), + ...(displayName !== undefined ? { displayName } : {}), + }; +} + +function personMatchesTeamsActor( + person: PersonIdentity, + input: { + readonly aadObjectId?: string | null | undefined; + readonly userId?: string | null | undefined; + }, +): boolean { + const aad = normalizeTeamsAadObjectId(input.aadObjectId); + const userId = input.userId?.trim().toLowerCase() ?? ""; + const userAsAad = normalizeTeamsAadObjectId(input.userId); + const mappedAad = person.teams?.aadObjectId?.toLowerCase(); + if (mappedAad !== undefined && mappedAad.length > 0) { + if (aad !== null && mappedAad === aad) return true; + if (userAsAad !== null && mappedAad === userAsAad) return true; + } + const mappedUser = person.teams?.userId?.trim().toLowerCase(); + return ( + mappedUser !== undefined && mappedUser.length > 0 && userId.length > 0 && mappedUser === userId + ); +} + function normalizeLogin(value: unknown): string | undefined { const raw = asNonEmptyString(value); if (raw === undefined) return undefined; @@ -147,10 +227,16 @@ function parsePerson(raw: unknown, indexLabel: string): PersonIdentity { asNonEmptyString(jiraNested?.displayName) ?? asNonEmptyString(raw.jiraDisplayName) ?? asNonEmptyString(raw.jira_display_name); + const teams = parseTeamsRef(raw, indexLabel); - if (discordId === undefined && githubLogin === undefined && jiraAccountId === undefined) { + if ( + discordId === undefined && + githubLogin === undefined && + jiraAccountId === undefined && + teams === undefined + ) { throw new Error( - `Identity map entry ${indexLabel} ("${name}") needs at least one of discord.id, github.login, or jira.accountId.`, + `Identity map entry ${indexLabel} ("${name}") needs at least one of discord.id, github.login, jira.accountId, or teams.aadObjectId.`, ); } @@ -183,6 +269,7 @@ function parsePerson(raw: unknown, indexLabel: string): PersonIdentity { }, } : {}), + ...(teams !== undefined ? { teams } : {}), }; } @@ -459,6 +546,55 @@ export function classifyDiscordAgentAccess(input: { return { allowed: true, person: resolved.person }; } +export type TeamsAgentAccessDecision = + | { readonly allowed: true; readonly person: PersonIdentity } + | { + readonly allowed: false; + readonly reason: "identity_map_empty" | "unmapped_teams_actor" | "missing_teams_actor"; + readonly userMessage: string; + }; + +/** + * Fail-closed agent gate for Teams requesters. Same closed set as Discord: + * empty map and unmapped actors cannot start, continue, stop, or approve a thread. + */ +export function classifyTeamsAgentAccess(input: { + readonly people: ReadonlyArray; + readonly aadObjectId?: string | null | undefined; + readonly userId?: string | null | undefined; + readonly displayName?: string | null | undefined; +}): TeamsAgentAccessDecision { + if (input.people.length === 0) { + return { + allowed: false, + reason: "identity_map_empty", + userMessage: + "You're not authorized to run agent work from Teams. An operator needs to configure the identity map before anyone can use the agent.", + }; + } + const aad = input.aadObjectId?.trim() ?? ""; + const userId = input.userId?.trim() ?? ""; + if (aad.length === 0 && userId.length === 0) { + return { + allowed: false, + reason: "missing_teams_actor", + userMessage: + "You're not authorized to run agent work from Teams. Ask an operator to add your Teams account to the allowlist.", + }; + } + const person = + input.people.find((candidate) => personMatchesTeamsActor(candidate, input)) ?? null; + if (person === null) { + return { + allowed: false, + reason: "unmapped_teams_actor", + userMessage: + "You're not authorized to run agent work from Teams. Ask an operator to add your Teams account to the allowlist.", + }; + } + return { allowed: true, person }; +} + export interface IdentityMapStoreService { readonly list: () => ReadonlyArray; readonly resolveByDiscordId: (discordId: string) => PersonIdentity | null; @@ -589,7 +725,7 @@ export const layerFromOptionalPath = (filePath: string | undefined) => Effect.gen(function* () { if (filePath === undefined || filePath.trim().length === 0) { yield* Effect.logInfo( - "T3_IDENTITY_MAP_PATH is unset; Discord identity authorization is unavailable until configured.", + "T3_IDENTITY_MAP_PATH is unset; Discord and Teams identity authorization is unavailable until configured.", ); return makeIdentityMapStore([]); } diff --git a/apps/discord-bot/src/teams-main.ts b/apps/discord-bot/src/teams-main.ts index 853cbba87fe7..f4bb250226c7 100644 --- a/apps/discord-bot/src/teams-main.ts +++ b/apps/discord-bot/src/teams-main.ts @@ -7,6 +7,7 @@ import * as Logger from "effect/Logger"; import { DiscordBotConfig } from "./config.ts"; import { runTeamsNativeApp } from "./features/TeamsNativeApp.ts"; +import { layerFromOptionalPath as identityMapStoreLayer } from "./identityMap.ts"; import { layerFromOptionalPath as projectAliasStoreLayer } from "./projectAliases.ts"; import { layer as threadLinkStoreLayer } from "./store/ThreadLinkStore.ts"; import { T3Session, layer as t3SessionLayer } from "./t3/T3Session.ts"; @@ -18,6 +19,7 @@ const TeamsMainLayer = Layer.unwrap( t3SessionLayer(config), threadLinkStoreLayer(config.dataDir), projectAliasStoreLayer(config.projectAliasesPath), + identityMapStoreLayer(config.identityMapPath), ).pipe( Layer.provideMerge(ConfigProvider.layer(ConfigProvider.fromEnv())), Layer.provideMerge(Logger.layer([Logger.consolePretty()])), diff --git a/apps/discord-bot/src/teams/presentation.test.ts b/apps/discord-bot/src/teams/presentation.test.ts index 9bd37b0f7102..a8d64d23e183 100644 --- a/apps/discord-bot/src/teams/presentation.test.ts +++ b/apps/discord-bot/src/teams/presentation.test.ts @@ -4,6 +4,7 @@ import { hasAllowlistedReaction, hasInternalTagTrigger, looksLikeGermanProblemReport, + resolveTeamsTriggerActor, teamsMessageText, } from "./presentation.ts"; @@ -78,3 +79,42 @@ describe("hasInternalTagTrigger", () => { ).toBe(true); }); }); + +describe("resolveTeamsTriggerActor", () => { + it("attributes a reaction to the reactor, not the message author", () => { + expect( + resolveTeamsTriggerActor({ + reason: "allowlisted-reaction", + message: { + id: "1", + from: { user: { id: "customer", displayName: "Customer" } }, + reactions: [ + { + reactionType: "eyes", + user: { + user: { id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", displayName: "Patrick" }, + }, + }, + ], + }, + allowlistedUserIds: ["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"], + reactionTriggerTypes: ["eyes"], + }), + ).toEqual({ + userId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + displayName: "Patrick", + }); + }); + + it("attributes a mention to the message author", () => { + expect( + resolveTeamsTriggerActor({ + reason: "mention", + message: { + id: "1", + from: { user: { id: "29:patroza", displayName: "Patrick" } }, + }, + }), + ).toEqual({ userId: "29:patroza", displayName: "Patrick" }); + }); +}); diff --git a/apps/discord-bot/src/teams/presentation.ts b/apps/discord-bot/src/teams/presentation.ts index 0b559d230113..80c7a728cf62 100644 --- a/apps/discord-bot/src/teams/presentation.ts +++ b/apps/discord-bot/src/teams/presentation.ts @@ -236,6 +236,58 @@ export function hasInternalTagTrigger(input: { }); } +export type TeamsTriggerReason = + | "mention" + | "german-problem" + | "allowlisted-reaction" + | "internal-tag"; + +function teamsAuthor(message: TeamsMessage): { + readonly userId: string | null; + readonly displayName: string | null; +} { + const userId = message.from?.user?.id?.trim() || null; + const displayName = message.from?.user?.displayName?.trim() || null; + return { + userId: userId !== null && userId.length > 0 ? userId : null, + displayName: displayName !== null && displayName.length > 0 ? displayName : null, + }; +} + +/** + * The person whose action started or continued the thread. + * Reactions and internal tags attribute the reactor/tagger, not the quoted customer. + */ +export function resolveTeamsTriggerActor(input: { + readonly reason: TeamsTriggerReason; + readonly message: TeamsMessage; + readonly triggerMessage?: TeamsMessage | undefined; + readonly allowlistedUserIds?: ReadonlyArray | undefined; + readonly reactionTriggerTypes?: ReadonlyArray | undefined; +}): { readonly userId: string | null; readonly displayName: string | null } { + if (input.reason === "internal-tag") { + return teamsAuthor(input.triggerMessage ?? input.message); + } + if (input.reason === "allowlisted-reaction") { + const reaction = (input.message.reactions ?? []).find((candidate) => { + const reactionType = normalizedText(candidate.reactionType); + const reactingUserId = normalizedText(candidate.user?.user?.id); + if (reactionType.length === 0 || reactingUserId.length === 0) return false; + const typeMatch = (input.reactionTriggerTypes ?? []).some( + (trigger) => normalizedText(trigger) === reactionType, + ); + const userMatch = (input.allowlistedUserIds ?? []).some( + (userId) => normalizedText(userId) === reactingUserId, + ); + return typeMatch && userMatch; + }); + const userId = reaction?.user?.user?.id?.trim() || null; + const displayName = reaction?.user?.user?.displayName?.trim() || null; + return { userId, displayName }; + } + return teamsAuthor(input.message); +} + export function buildTeamsIncidentTitle(input: { readonly company: string; readonly environment: string; diff --git a/packages/contracts/src/identity.ts b/packages/contracts/src/identity.ts index 6f74d6bbfe40..8f17199ee0fd 100644 --- a/packages/contracts/src/identity.ts +++ b/packages/contracts/src/identity.ts @@ -133,6 +133,7 @@ export const IdentityPlatformLinkPublic = Schema.Struct({ discordUsername: Schema.optionalKey(TrimmedNonEmptyString), githubLogin: Schema.optionalKey(TrimmedNonEmptyString), jiraAccountId: Schema.optionalKey(TrimmedNonEmptyString), + teamsAadObjectId: Schema.optionalKey(TrimmedNonEmptyString), }); export type IdentityPlatformLinkPublic = typeof IdentityPlatformLinkPublic.Type; diff --git a/packages/shared/src/identityMap.test.ts b/packages/shared/src/identityMap.test.ts index dda0239d8511..037db81bfe7e 100644 --- a/packages/shared/src/identityMap.test.ts +++ b/packages/shared/src/identityMap.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { IdentityMapParseError, normalizeJiraAccountId, + findPersonByTeamsActor, parseIdentityMapDocument, resolvePersonByJiraAccountId, } from "./identityMap.ts"; @@ -66,4 +67,21 @@ describe("parseIdentityMapDocument", () => { expect(resolvePersonByJiraAccountId(people, "712020:abc")?.username).toBe("patroza"); expect(resolvePersonByJiraAccountId(people, "nope")).toBeNull(); }); + + it("resolves people by Teams Azure AD object id", () => { + const people = parseIdentityMapDocument({ + people: { + patroza: { + username: "patroza", + teamsAadObjectId: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", + teamsUserId: "29:patroza", + }, + }, + }); + expect( + findPersonByTeamsActor(people, { userId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" })?.username, + ).toBe("patroza"); + expect(findPersonByTeamsActor(people, { userId: "29:patroza" })?.username).toBe("patroza"); + expect(findPersonByTeamsActor(people, { aadObjectId: "nope" })).toBeNull(); + }); }); diff --git a/packages/shared/src/identityMap.ts b/packages/shared/src/identityMap.ts index 678d39962409..2c79a9048a9c 100644 --- a/packages/shared/src/identityMap.ts +++ b/packages/shared/src/identityMap.ts @@ -24,6 +24,14 @@ export type IdentityMapJiraRef = { readonly displayName?: string | undefined; }; +export type IdentityMapTeamsRef = { + /** Azure AD object id (GUID). Graph `from.user.id` and Bot Framework `from.aadObjectId`. */ + readonly aadObjectId?: string | undefined; + /** Bot Framework `from.id` when it is not the Azure AD object id (`29:…`). */ + readonly userId?: string | undefined; + readonly displayName?: string | undefined; +}; + export type IdentityMapPerson = { readonly personId: string; readonly username: string; @@ -31,6 +39,7 @@ export type IdentityMapPerson = { readonly discord?: IdentityMapDiscordRef | undefined; readonly github?: IdentityMapGitHubRef | undefined; readonly jira?: IdentityMapJiraRef | undefined; + readonly teams?: IdentityMapTeamsRef | undefined; }; export class IdentityMapParseError extends Error { @@ -78,6 +87,58 @@ function asDiscordSnowflake(value: unknown): string | undefined { return raw; } +const TEAMS_AAD_OBJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +/** Normalize an Azure AD object id. Returns null when the value is not a GUID. */ +export function normalizeTeamsAadObjectId(value: string | null | undefined): string | null { + if (value === null || value === undefined) return null; + const trimmed = value.trim().replace(/^\{|\}$/gu, ""); + if (!TEAMS_AAD_OBJECT_ID.test(trimmed)) return null; + return trimmed.toLowerCase(); +} + +function parseTeamsRef( + raw: Record, + indexLabel: string, +): IdentityMapTeamsRef | undefined { + const teamsNested = isRecord(raw.teams) ? raw.teams : undefined; + const explicitAad = + asNonEmptyString(teamsNested?.aadObjectId) ?? + asNonEmptyString(teamsNested?.aad_object_id) ?? + asNonEmptyString(raw.teamsAadObjectId) ?? + asNonEmptyString(raw.teams_aad_object_id); + if (explicitAad !== undefined && normalizeTeamsAadObjectId(explicitAad) === null) { + throw new IdentityMapParseError( + indexLabel, + "teams.aadObjectId must be an Azure AD object id (GUID)", + ); + } + let aadObjectId = normalizeTeamsAadObjectId(explicitAad) ?? undefined; + const userIdRaw = + asNonEmptyString(teamsNested?.userId) ?? + asNonEmptyString(teamsNested?.user_id) ?? + asNonEmptyString(raw.teamsUserId) ?? + asNonEmptyString(raw.teams_user_id); + let userId = userIdRaw; + if (aadObjectId === undefined && userIdRaw !== undefined) { + const userAsAad = normalizeTeamsAadObjectId(userIdRaw); + if (userAsAad !== null) { + aadObjectId = userAsAad; + userId = undefined; + } + } + const displayName = + asNonEmptyString(teamsNested?.displayName) ?? + asNonEmptyString(raw.teamsDisplayName) ?? + asNonEmptyString(raw.teams_display_name); + if (aadObjectId === undefined && userId === undefined) return undefined; + return { + ...(aadObjectId !== undefined ? { aadObjectId } : {}), + ...(userId !== undefined ? { userId } : {}), + ...(displayName !== undefined ? { displayName } : {}), + }; +} + function normalizeLogin(value: unknown): string | undefined { const raw = asNonEmptyString(value); if (raw === undefined) return undefined; @@ -151,6 +212,7 @@ function parsePerson(raw: unknown, indexLabel: string, keyHint?: string): Identi asNonEmptyString(jiraNested?.displayName) ?? asNonEmptyString(raw.jiraDisplayName) ?? asNonEmptyString(raw.jira_display_name); + const teams = parseTeamsRef(raw, indexLabel); return { personId, @@ -183,6 +245,7 @@ function parsePerson(raw: unknown, indexLabel: string, keyHint?: string): Identi }, } : {}), + ...(teams !== undefined ? { teams } : {}), }; } @@ -239,6 +302,9 @@ export function toIdentityPersonPublic(person: IdentityMapPerson) { : {}), ...(person.github?.login !== undefined ? { githubLogin: person.github.login } : {}), ...(person.jira?.accountId !== undefined ? { jiraAccountId: person.jira.accountId } : {}), + ...(person.teams?.aadObjectId !== undefined + ? { teamsAadObjectId: person.teams.aadObjectId } + : {}), }, }; } @@ -330,6 +396,36 @@ export function findPersonByJiraAccountId( return resolvePersonByJiraAccountId(people, accountId); } +/** Resolve a closed-set person by Teams Azure AD object id or Bot Framework user id. */ +export function findPersonByTeamsActor( + people: ReadonlyArray, + input: { + readonly aadObjectId?: string | null | undefined; + readonly userId?: string | null | undefined; + }, +): IdentityMapPerson | null { + const aad = normalizeTeamsAadObjectId(input.aadObjectId); + const userId = input.userId?.trim().toLowerCase() ?? ""; + const userAsAad = normalizeTeamsAadObjectId(input.userId); + for (const person of people) { + const mappedAad = person.teams?.aadObjectId?.toLowerCase(); + if (mappedAad !== undefined && mappedAad.length > 0) { + if (aad !== null && mappedAad === aad) return person; + if (userAsAad !== null && mappedAad === userAsAad) return person; + } + const mappedUser = person.teams?.userId?.trim().toLowerCase(); + if ( + mappedUser !== undefined && + mappedUser.length > 0 && + userId.length > 0 && + mappedUser === userId + ) { + return person; + } + } + return null; +} + export function findPersonByJiraEmail( people: ReadonlyArray, email: string,