Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/discord-bot/src/features/TeamsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,6 +23,7 @@ import {
isHumanTeamsMessage,
looksLikeGermanProblemReport,
mentionsTeamsBot,
resolveTeamsTriggerActor,
rootTeamsMessageId,
teamsMessageTimestamp,
type TeamsMessage,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
40 changes: 40 additions & 0 deletions apps/discord-bot/src/features/TeamsNativeApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<PersonIdentity>,
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";
}
Expand Down Expand Up @@ -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<ProjectAliasStore | T3Session | ThreadLinkStore>();
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions apps/discord-bot/src/identityMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
parseIdentityMapDocument,
parseSimpleIdentityYaml,
classifyDiscordAgentAccess,
classifyTeamsAgentAccess,
resolveParticipantIdentity,
} from "./identityMap.ts";

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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-"));
Expand Down
Loading
Loading