From 667e89409be6f302c74e3e9913deea34555cb0b7 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Mon, 9 Nov 2026 11:09:26 +0100 Subject: [PATCH 1/2] feat(snapshot): expose each subject's Microsoft 365 object ID The backend needs to know which Microsoft 365 account a signed-in person is, so a socio can see their own register entry and fee in My area (People Phase 2). Every backend snapshot subject now carries `entraObjectId`: the `externalId` (Entra `oid`) of the account's `pn-entra` proof, the same ID that proves socio and direttivo. It is null without a Microsoft 365 sign-in, and, like `telegramId`, when the link is ambiguous: one account linked to several object IDs, or one object ID linked to several accounts. The Telegram uniqueness rule is now shared by both links. The field is additive to polinetwork.access-snapshot/v1. Closes #84 --- README.md | 4 +- src/auth/access-projections.ts | 2 +- src/auth/access-snapshot.integration.test.ts | 1 + src/auth/access-snapshot.test.ts | 44 ++++++++++++ src/auth/access-snapshot.ts | 73 +++++++++++++------- 5 files changed, 98 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 697bb9a..1237528 100644 --- a/README.md +++ b/README.md @@ -334,10 +334,12 @@ Dynamic registration is open to AI agents only, for the [MCP server](#mcp-server ### Phase 1 backend access snapshot -Set `OAUTH_BACKEND_RESOURCE_URI` and `OAUTH_INTERNAL_RESOURCE_URI` to distinct HTTPS audience identifiers, then set `OAUTH_BACKEND_CLIENT_ID` to the registered backend snapshot-pull client's ID. A client-credentials token for the internal resource needs `idp:access:read`; the authenticated client ID selects the reviewed `backend` projection. `GET /api/internal/access-snapshot` returns a versioned list of subjects with backend permissions and effective roles, their individual expiry times, and linked Telegram IDs, plus the names of every role and backend permission. An exact `If-None-Match` match returns `304`; a new Graph observation changes the ETag even if membership is unchanged. Unknown clients and missing audience or scope are rejected. +Set `OAUTH_BACKEND_RESOURCE_URI` and `OAUTH_INTERNAL_RESOURCE_URI` to distinct HTTPS audience identifiers, then set `OAUTH_BACKEND_CLIENT_ID` to the registered backend snapshot-pull client's ID. A client-credentials token for the internal resource needs `idp:access:read`; the authenticated client ID selects the reviewed `backend` projection. `GET /api/internal/access-snapshot` returns a versioned list of subjects with backend permissions and effective roles, their individual expiry times, linked Telegram IDs and Microsoft 365 object IDs, plus the names of every role and backend permission. An exact `If-None-Match` match returns `304`; a new Graph observation changes the ETag even if membership is unchanged. Unknown clients and missing audience or scope are rejected. Entra direct-membership observations are stored by group. A pull lists a group through Graph only if no replica has observed it in the last 15 seconds, and concurrent pulls on one replica share that listing. A failed Graph request preserves the last observation, marks that source degraded, and lets its permissions expire one hour after the last success. Student rights expire at the verification timestamp. Manual assignments do not expire. Telegram IDs are included only while the linked account exists, regardless of the Telegram ID token's expiry. Duplicate Telegram IDs are excluded from the projection. +Each subject's `entraObjectId` is the Entra object ID (`oid`) of the PoliNetwork Microsoft 365 account it signed in with: the `externalId` of its `pn-entra` proof, the same ID that proves `socio` and `direttivo`. The backend uses it to find the person's own register entry. Like `telegramId`, it is `null` when the account has no Microsoft 365 sign-in, when it is linked to several Microsoft 365 accounts, or when one Microsoft 365 account is linked to several accounts here. It stays while the linked account exists, whatever the group membership, so it identifies former soci too, but only subjects holding a backend permission appear in the snapshot. + Database triggers enqueue changes to the snapshot's inputs in `access_outbox`. Rewrites that leave the projected columns unchanged, such as sign-ins, are ignored, and at most one undelivered row is kept per projection, so the table stays small while no receiver is configured. Once the backend receiver exists, set `OAUTH_BACKEND_EVENTS_URL` to its `/internal/events` endpoint. The production server then claims the queued rows, sends one IdP-signed security-event token for them and retries with backoff until the receiver replies `202`; the backend must still poll the snapshot because push delivery is best-effort. Leave the URL unset until the receiver is deployed. JWT signing keys rotate every seven days and public keys overlap for thirty days. Audit rows record Telegram links, client changes and student verification without storing client secrets. Master Admin registers the four Phase 1 clients on `/applications/new`, each with a private key (its public Ed25519 or P-256 JWKS) and the grants and scopes reviewed in [RFC ยง9](docs/idp-telegram-integration-rfc-v3.md): Telegram Bot and Website call APIs as themselves with their `backend:tg:*` and `backend:public:read` scopes, Backend snapshot pull with `idp:access:read`, and Admin Dashboard signs people in with `openid profile email offline_access` and calls `backend:admin` for them, with sign-out ending the PoliNetwork session. The resources those scopes belong to are linked on save. The application page edits the JWKS with overlap during rotation: add the new public key, deploy the matching private key to the owning service, then remove the old public key after the overlap. Other application writers cannot edit or delete clients with API access. Record the generated client IDs in deployment configuration; the backend snapshot-pull ID must match `OAUTH_BACKEND_CLIENT_ID`. Private keys belong in the owning service's Key Vault secret and must never be pasted into this IdP. diff --git a/src/auth/access-projections.ts b/src/auth/access-projections.ts index 545b985..b23276c 100644 --- a/src/auth/access-projections.ts +++ b/src/auth/access-projections.ts @@ -8,7 +8,7 @@ import { env } from "../env"; export const ACCESS_PROJECTIONS = { backend: { clients: env.OAUTH_BACKEND_CLIENT_ID ? [env.OAUTH_BACKEND_CLIENT_ID] : [], - fields: ["telegramId"], + fields: ["telegramId", "entraObjectId"], }, } as const; diff --git a/src/auth/access-snapshot.integration.test.ts b/src/auth/access-snapshot.integration.test.ts index a49b05f..2a0a65f 100644 --- a/src/auth/access-snapshot.integration.test.ts +++ b/src/auth/access-snapshot.integration.test.ts @@ -117,6 +117,7 @@ describe.skipIf(!baseURL || !databaseURL || !clientId || !audience)( { sub: "snapshot-integration-user", telegramId: "987654321", + entraObjectId: null, roles: { "snapshot-integration-role": { validUntil: null } }, permissions: { "tg:snapshot:test": { validUntil: null } }, }, diff --git a/src/auth/access-snapshot.test.ts b/src/auth/access-snapshot.test.ts index 41efed6..74da588 100644 --- a/src/auth/access-snapshot.test.ts +++ b/src/auth/access-snapshot.test.ts @@ -111,12 +111,14 @@ describe("backend access projection", () => { { sub: "entra-user", telegramId: null, + entraObjectId: "e", roles: { socio: { validUntil: "2026-10-08T10:30:00.000Z" } }, permissions: { "tg:read": { validUntil: "2026-10-08T10:30:00.000Z" } }, }, { sub: "manual-user", telegramId: "123456", + entraObjectId: "m", roles: { manual: { validUntil: null }, socio: { validUntil: "2026-10-08T10:30:00.000Z" }, @@ -129,6 +131,7 @@ describe("backend access projection", () => { { sub: "root", telegramId: null, + entraObjectId: null, roles: { "master-admin": { validUntil: null } }, permissions: { "admin:access": { validUntil: null }, @@ -139,6 +142,7 @@ describe("backend access projection", () => { { sub: "student-user", telegramId: null, + entraObjectId: null, roles: { student: { validUntil: "2026-10-08T10:30:00.000Z" } }, permissions: { "tg:moderate": { validUntil: "2026-10-08T10:30:00.000Z" }, @@ -169,6 +173,45 @@ describe("backend access projection", () => { expect(subjects[0]?.permissions["tg:read"]).toEqual({ validUntil: null }); }); + it("carries each account's Microsoft 365 object ID unless the link is ambiguous", () => { + const subjects = projectBackendSubjects({ + users: ["single", "relinked", "twice", "shared-a", "shared-b", "foreign", "none"], + manualRoles: ["single", "relinked", "twice", "shared-a", "shared-b", "foreign", "none"].map( + (userId) => ({ userId, roleKey: "manual" }), + ), + proofs: [ + pnProof("single", "oid-single"), + // The same Microsoft 365 account signed in twice is still one link. + pnProof("relinked", "oid-relinked"), + pnProof("relinked", "oid-relinked"), + pnProof("twice", "oid-twice-1"), + pnProof("twice", "oid-twice-2"), + pnProof("shared-a", "oid-shared"), + pnProof("shared-b", "oid-shared"), + { + ...pnProof("foreign", "oid-foreign"), + issuer: "https://login.microsoftonline.com/other/v2.0", + }, + tgProof("none", "77"), + ], + catalog, + groups: [], + observations: [], + now, + }); + expect( + Object.fromEntries(subjects.map(({ sub, entraObjectId }) => [sub, entraObjectId])), + ).toEqual({ + foreign: null, + none: null, + relinked: "oid-relinked", + "shared-a": null, + "shared-b": null, + single: "oid-single", + twice: null, + }); + }); + it("carries permissions created in the dashboard but never the identity provider's own", () => { const subjects = projectBackendSubjects({ users: ["signer"], @@ -186,6 +229,7 @@ describe("backend access projection", () => { { sub: "signer", telegramId: null, + entraObjectId: null, roles: { secretary: { validUntil: null } }, permissions: { "esign:sign": { validUntil: null } }, }, diff --git a/src/auth/access-snapshot.ts b/src/auth/access-snapshot.ts index ae5e85d..cf4ba3c 100644 --- a/src/auth/access-snapshot.ts +++ b/src/auth/access-snapshot.ts @@ -29,6 +29,8 @@ export type AccessSource = { export type AccessSubject = { sub: string; telegramId: string | null; + /** Microsoft 365 (Entra) object ID of the linked PoliNetwork account; null when none or ambiguous. */ + entraObjectId: string | null; /** Effective roles: those granted plus every role they inherit from. */ roles: Record; permissions: Record; @@ -141,6 +143,39 @@ function byKey(held: Map): Record string | null, + label: string, +): Map { + const byUser = new Map>(); + for (const proof of proofs) { + const id = idOf(proof); + if (!id) continue; + const ids = byUser.get(proof.userId) ?? new Set(); + ids.add(id); + byUser.set(proof.userId, ids); + } + const owners = new Map(); + for (const ids of byUser.values()) + for (const id of ids) owners.set(id, (owners.get(id) ?? 0) + 1); + const ambiguous = + [...owners.values()].filter((count) => count > 1).length + + [...byUser.values()].filter((ids) => ids.size > 1).length; + if (ambiguous) + console.error(`Access snapshot excluded ambiguous ${label} links.`, { count: ambiguous }); + const unique = new Map(); + for (const [userId, ids] of byUser) { + const [id] = ids; + if (ids.size === 1 && owners.get(id!) === 1) unique.set(userId, id!); + } + return unique; +} + /** Names for the backend's pickers: every role, and the permissions the projection carries. */ export function projectBackendCatalog(catalog: RbacCatalog): AccessCatalog { const named = (entries: { key: string; name: string }[]) => @@ -216,38 +251,28 @@ export function projectBackendSubjects(input: { } } - const telegramIds = new Map>(); - for (const proof of proofs) { - if ( - proof.providerId !== "telegram" || - proof.issuer !== "https://oauth.telegram.org" || - !proof.telegramId - ) - continue; - const ids = telegramIds.get(proof.userId) ?? new Set(); - ids.add(proof.telegramId); - telegramIds.set(proof.userId, ids); - } - const owners = new Map(); - for (const ids of telegramIds.values()) - for (const id of ids) owners.set(id, (owners.get(id) ?? 0) + 1); - const ambiguous = - [...owners.values()].filter((count) => count > 1).length + - [...telegramIds.values()].filter((ids) => ids.size > 1).length; - if (ambiguous) - console.error("Access snapshot excluded ambiguous Telegram links.", { count: ambiguous }); + const telegramIds = uniqueLinks( + proofs.filter( + (proof) => proof.providerId === "telegram" && proof.issuer === "https://oauth.telegram.org", + ), + (proof) => proof.telegramId, + "Telegram", + ); + const entraObjectIds = uniqueLinks( + proofs.filter((proof) => proof.providerId === "pn-entra" && proof.issuer === entraIssuer), + (proof) => proof.externalId, + "Microsoft 365", + ); return users .flatMap((sub): AccessSubject[] => { const access = held.get(sub); if (!access?.permissions.size) return []; - const ids = telegramIds.get(sub); - const id = ids?.size === 1 ? [...ids][0]! : null; - const telegramId = id && owners.get(id) === 1 ? id : null; return [ { sub, - telegramId, + telegramId: telegramIds.get(sub) ?? null, + entraObjectId: entraObjectIds.get(sub) ?? null, roles: byKey(access.roles), permissions: byKey(access.permissions), }, From 47cf5cdff844c00f8228f9f1d0d2e32e2517f31f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:09:56 +0000 Subject: [PATCH 2/2] fix(auth): Deduplicate ambiguous link logs and separate user and ID counts --- src/auth/access-snapshot.test.ts | 57 ++++++++++++++++++++++++++++++++ src/auth/access-snapshot.ts | 29 +++++++++++++--- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/auth/access-snapshot.test.ts b/src/auth/access-snapshot.test.ts index 74da588..ce9873a 100644 --- a/src/auth/access-snapshot.test.ts +++ b/src/auth/access-snapshot.test.ts @@ -212,6 +212,63 @@ describe("backend access projection", () => { }); }); + it("logs only changes to ambiguous links, with separate user and ID counts", () => { + const project = (proofs: Parameters[0]["proofs"]) => + projectBackendSubjects({ + users: [], + manualRoles: [], + proofs, + catalog, + groups: [], + observations: [], + now, + }); + project([]); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const proofs = [ + tgProof("a", "42"), + tgProof("b", "42"), + tgProof("c", "99"), + tgProof("c", "100"), + ]; + project(proofs); + expect(error).toHaveBeenLastCalledWith("Access snapshot excluded ambiguous Telegram links.", { + userCount: 1, + idCount: 1, + }); + project([...proofs].reverse().concat(proofs[0]!, tgProof("unique", "unique"))); + expect(error).toHaveBeenCalledTimes(1); + // Changing an ID or an owner matters even when the counts stay the same. + const changedId = proofs.map((proof) => + proof.telegramId === "42" ? { ...proof, telegramId: "43" } : proof, + ); + project(changedId); + expect(error).toHaveBeenCalledTimes(2); + const changedUser = changedId.map((proof) => + proof.userId === "a" ? { ...proof, userId: "d" } : proof, + ); + project(changedUser); + expect(error).toHaveBeenCalledTimes(3); + project( + changedUser.map((proof) => ({ + ...pnProof(proof.userId, proof.telegramId!), + telegramId: null, + })), + ); + expect(error).toHaveBeenLastCalledWith( + "Access snapshot excluded ambiguous Microsoft 365 links.", + { userCount: 1, idCount: 1 }, + ); + project([]); + project(proofs); + expect(error).toHaveBeenCalledTimes(5); + } finally { + project([]); + error.mockRestore(); + } + }); + it("carries permissions created in the dashboard but never the identity provider's own", () => { const subjects = projectBackendSubjects({ users: ["signer"], diff --git a/src/auth/access-snapshot.ts b/src/auth/access-snapshot.ts index cf4ba3c..674eb6a 100644 --- a/src/auth/access-snapshot.ts +++ b/src/auth/access-snapshot.ts @@ -143,6 +143,9 @@ function byKey(held: Map): Record(); + /** * Each account's single linked external ID. Someone linked to several IDs, or an ID linked to * several accounts, gets none: the backend must never act on the wrong person. @@ -163,11 +166,27 @@ function uniqueLinks( const owners = new Map(); for (const ids of byUser.values()) for (const id of ids) owners.set(id, (owners.get(id) ?? 0) + 1); - const ambiguous = - [...owners.values()].filter((count) => count > 1).length + - [...byUser.values()].filter((ids) => ids.size > 1).length; - if (ambiguous) - console.error(`Access snapshot excluded ambiguous ${label} links.`, { count: ambiguous }); + const ambiguousUsers = [...byUser.values()].filter((ids) => ids.size > 1).length; + const ambiguousIds = [...owners.values()].filter((count) => count > 1).length; + if (ambiguousUsers || ambiguousIds) { + const links = [...byUser.entries()] + .map( + ([userId, ids]) => + [userId, [...ids].filter((id) => ids.size > 1 || owners.get(id)! > 1).sort()] as const, + ) + .filter(([, ids]) => ids.length > 0) + .sort(([a], [b]) => a.localeCompare(b)); + const fingerprint = createHash("sha256").update(JSON.stringify(links)).digest("hex"); + if (ambiguityFingerprints.get(label) !== fingerprint) { + console.error(`Access snapshot excluded ambiguous ${label} links.`, { + userCount: ambiguousUsers, + idCount: ambiguousIds, + }); + ambiguityFingerprints.set(label, fingerprint); + } + } else { + ambiguityFingerprints.delete(label); + } const unique = new Map(); for (const [userId, ids] of byUser) { const [id] = ids;