From 09106bcca1e8aa5656a1b5bb74f7c432a261d65a Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Mon, 9 Nov 2026 11:10:35 +0100 Subject: [PATCH 1/2] feat(snapshot): expose each subject's Microsoft 365 object ID The IdP now sends `entraObjectId` on each snapshot subject: the Entra object ID of the PoliNetwork Microsoft 365 account the person signs in with, or null when there is none or the link is ambiguous (PoliNetworkOrg/auth#84). The backend uses it to show a socio their own register entry and fee. AccessSubject gains `entraObjectId: string | null`. The field is optional in the consumer schema, so snapshots from older IdPs, including persisted ones, still parse, with null. --- docs/contract.md | 1 + fixtures/snapshot-v1.json | 5 +++++ src/contract/snapshot.ts | 4 ++++ src/snapshot/access-index.ts | 6 ++++++ tests/snapshot.test.ts | 20 ++++++++++++++++++-- 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index 675a033..538a3fa 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -49,6 +49,7 @@ The executable form of this contract is the `@polinetwork/auth-kit/contract` ent - **Absent means no permissions.** People without a projected permission are omitted, including people who linked Telegram. An unknown `sub` or Telegram ID is not an error. - **`telegramId` may be `null` for someone who linked Telegram,** when the link is ambiguous (one person with several IDs, or one ID shared by several people). Duplicates therefore cannot appear; the SDK still rejects them as a backstop. - **`telegramId` is a decimal string** matching `^[1-9]\d*$`. +- **`entraObjectId` is the Entra object ID (`oid`)** of the PoliNetwork Microsoft 365 account the subject signs in with, the one that proves `socio` and `direttivo`. Use it to find the person's own records keyed by their Microsoft 365 account. Like `telegramId`, it is `null` when the subject has no Microsoft 365 sign-in or the link is ambiguous (one person with several Microsoft 365 accounts, or one account shared by several people), so it never appears twice. It stays while the account is linked, whatever the group membership. IdPs before it was projected omit it, and the SDK reads that as `null`. - **Until Phase 2** creates the `tg:*` permissions, the production snapshot is nearly empty. Test against a local IdP with seeded permissions. ### 2.2 Format details **(differs from RFC)** diff --git a/fixtures/snapshot-v1.json b/fixtures/snapshot-v1.json index 7f64670..4a60c79 100644 --- a/fixtures/snapshot-v1.json +++ b/fixtures/snapshot-v1.json @@ -139,6 +139,7 @@ { "sub": "usr_admin_breakglass", "telegramId": "100000001", + "entraObjectId": null, "roles": { "master-admin": { "validUntil": null @@ -207,6 +208,7 @@ { "sub": "usr_admin_entra", "telegramId": null, + "entraObjectId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", "roles": { "master-admin": { "validUntil": "2026-10-09T11:14:58.001Z" @@ -275,6 +277,7 @@ { "sub": "usr_direttivo", "telegramId": "555", + "entraObjectId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "roles": { "auditor": { "validUntil": null @@ -310,6 +313,7 @@ { "sub": "usr_moderator", "telegramId": "123456789", + "entraObjectId": null, "roles": { "moderator": { "validUntil": null @@ -333,6 +337,7 @@ { "sub": "usr_web", "telegramId": null, + "entraObjectId": null, "roles": { "web-editor": { "validUntil": null diff --git a/src/contract/snapshot.ts b/src/contract/snapshot.ts index 69b2b13..c0de56d 100644 --- a/src/contract/snapshot.ts +++ b/src/contract/snapshot.ts @@ -42,6 +42,8 @@ export const accessSnapshotSchema = z.object({ .string() .regex(/^[1-9]\d*$/) .nullable(), + /** Absent from IdPs before the Microsoft 365 object ID was projected. */ + entraObjectId: z.string().min(1).nullable().optional(), /** Absent from IdPs before roles were projected. */ roles: expiries.optional(), permissions: expiries, @@ -66,6 +68,8 @@ export type AccessSubjectV1 = { sub: string /** Decimal string; null when not linked or ambiguous. */ telegramId: string | null + /** Entra object ID (`oid`) of the linked PoliNetwork Microsoft 365 account; null when not linked or ambiguous. */ + entraObjectId: string | null /** Granted roles plus every role they inherit from; `validUntil: null` never expires. */ roles: Record /** Fully expanded keys; `validUntil: null` never expires. */ diff --git a/src/snapshot/access-index.ts b/src/snapshot/access-index.ts index fc71597..7a7a673 100644 --- a/src/snapshot/access-index.ts +++ b/src/snapshot/access-index.ts @@ -4,6 +4,11 @@ import { AuthKitError } from "../errors" export type AccessSubject = Readonly<{ sub: string telegramId: string | null + /** + * Entra object ID (`oid`) of the PoliNetwork Microsoft 365 account the subject signs in with; + * `null` when not linked, when ambiguous, or from IdPs before it was projected. + */ + entraObjectId: string | null /** Effective role key → expiry in epoch milliseconds; `null` never expires. */ roles: ReadonlyMap /** Permission key → expiry in epoch milliseconds; `null` never expires. */ @@ -83,6 +88,7 @@ export class AccessIndex { const subject: AccessSubject = Object.freeze({ sub: raw.sub, telegramId: raw.telegramId, + entraObjectId: raw.entraObjectId ?? null, roles: epochs(raw.roles), permissions: epochs(raw.permissions), }) diff --git a/tests/snapshot.test.ts b/tests/snapshot.test.ts index 076674e..3139716 100644 --- a/tests/snapshot.test.ts +++ b/tests/snapshot.test.ts @@ -5,11 +5,14 @@ import { fixture } from "./fixtures" const BUILT_AT = Date.parse("2026-10-09T10:15:02.123Z") const DIRETTIVO_EXPIRY = Date.parse("2026-10-09T11:14:31.456Z") -/** The v1 fixture as an IdP sent it before roles and the catalog were projected. */ +/** The v1 fixture as an IdP sent it before roles, the catalog and Microsoft 365 object IDs were projected. */ function withoutRoles(): string { const body = JSON.parse(fixture("snapshot-v1.json")) delete body.catalog - for (const subject of body.subjects) delete subject.roles + for (const subject of body.subjects) { + delete subject.roles + delete subject.entraObjectId + } return JSON.stringify(body) } @@ -29,6 +32,19 @@ describe("parseAccessSnapshot", () => { expect(index.bySub("usr_web")?.telegramId).toBeNull() }) + it("carries each subject's Microsoft 365 object ID, null when not linked", () => { + const index = parseAccessSnapshot(fixture("snapshot-v1.json"), "backend") + expect(index.bySub("usr_direttivo")?.entraObjectId).toBe("9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d") + expect(index.bySub("usr_moderator")?.entraObjectId).toBeNull() + expect(parseAccessSnapshot(withoutRoles(), "backend").bySub("usr_direttivo")?.entraObjectId).toBeNull() + }) + + it("rejects an empty Microsoft 365 object ID", () => { + const body = JSON.parse(fixture("snapshot-v1.json")) + body.subjects[0].entraObjectId = "" + expectInvalid(JSON.stringify(body)) + }) + it("treats unknown subjects and Telegram IDs as holding nothing", () => { const index = parseAccessSnapshot(fixture("snapshot-v1.json"), "backend") expect(index.bySub("usr_nobody")).toBeUndefined() From 57fc01a9fb6e969dce8c7765dca23bc15526d548 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:00:36 +0000 Subject: [PATCH 2/2] fix(snapshot): reject duplicate non-null Entra object IDs --- src/snapshot/access-index.ts | 8 +++++++- tests/snapshot.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/snapshot/access-index.ts b/src/snapshot/access-index.ts index 7a7a673..8d68089 100644 --- a/src/snapshot/access-index.ts +++ b/src/snapshot/access-index.ts @@ -80,10 +80,11 @@ export class AccessIndex { this.#byTelegramId = byTelegramId } - /** Throws `snapshot_invalid` on a duplicate `sub` or Telegram ID. */ + /** Throws `snapshot_invalid` on a duplicate `sub`, Telegram ID or non-null Entra object ID. */ static from(body: AccessSnapshotBody): AccessIndex { const bySub = new Map() const byTelegramId = new Map() + const entraObjectIds = new Set() for (const raw of body.subjects) { const subject: AccessSubject = Object.freeze({ sub: raw.sub, @@ -94,6 +95,11 @@ export class AccessIndex { }) if (bySub.has(subject.sub)) throw new AuthKitError("snapshot_invalid", `Duplicate sub ${subject.sub}`) bySub.set(subject.sub, subject) + if (subject.entraObjectId !== null) { + if (entraObjectIds.has(subject.entraObjectId)) + throw new AuthKitError("snapshot_invalid", `Duplicate Entra object ID ${subject.entraObjectId}`) + entraObjectIds.add(subject.entraObjectId) + } if (subject.telegramId === null) continue if (byTelegramId.has(subject.telegramId)) throw new AuthKitError("snapshot_invalid", `Duplicate Telegram ID ${subject.telegramId}`) diff --git a/tests/snapshot.test.ts b/tests/snapshot.test.ts index 3139716..393a1be 100644 --- a/tests/snapshot.test.ts +++ b/tests/snapshot.test.ts @@ -39,6 +39,30 @@ describe("parseAccessSnapshot", () => { expect(parseAccessSnapshot(withoutRoles(), "backend").bySub("usr_direttivo")?.entraObjectId).toBeNull() }) + it.each([null, "123456789"])("rejects duplicate Entra object IDs with Telegram ID %s", (telegramId) => { + const body = JSON.parse(fixture("snapshot-v1.json")) + body.subjects = [ + { sub: "first", telegramId, entraObjectId: "shared-oid", permissions: {} }, + { sub: "second", telegramId: null, entraObjectId: "shared-oid", permissions: {} }, + ] + expectInvalid(JSON.stringify(body)) + }) + + it("accepts unique Entra object IDs alongside multiple null or omitted IDs", () => { + const body = JSON.parse(fixture("snapshot-v1.json")) + body.subjects = ["first-oid", "second-oid", null, null, undefined, undefined].map((entraObjectId, i) => ({ + sub: `subject-${i}`, + telegramId: null, + entraObjectId, + permissions: {}, + })) + const index = parseAccessSnapshot(JSON.stringify(body), "backend") + expect(index.size).toBe(6) + expect(index.bySub("subject-0")?.entraObjectId).toBe("first-oid") + expect(index.bySub("subject-1")?.entraObjectId).toBe("second-oid") + for (let i = 2; i < 6; i++) expect(index.bySub(`subject-${i}`)?.entraObjectId).toBeNull() + }) + it("rejects an empty Microsoft 365 object ID", () => { const body = JSON.parse(fixture("snapshot-v1.json")) body.subjects[0].entraObjectId = ""