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..8d68089 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. */ @@ -75,19 +80,26 @@ 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, telegramId: raw.telegramId, + entraObjectId: raw.entraObjectId ?? null, roles: epochs(raw.roles), permissions: epochs(raw.permissions), }) 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 076674e..393a1be 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,43 @@ 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.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 = "" + 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()