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
1 change: 1 addition & 0 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**
Expand Down
5 changes: 5 additions & 0 deletions fixtures/snapshot-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
{
"sub": "usr_admin_breakglass",
"telegramId": "100000001",
"entraObjectId": null,
"roles": {
"master-admin": {
"validUntil": null
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -275,6 +277,7 @@
{
"sub": "usr_direttivo",
"telegramId": "555",
"entraObjectId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"roles": {
"auditor": {
"validUntil": null
Expand Down Expand Up @@ -310,6 +313,7 @@
{
"sub": "usr_moderator",
"telegramId": "123456789",
"entraObjectId": null,
"roles": {
"moderator": {
"validUntil": null
Expand All @@ -333,6 +337,7 @@
{
"sub": "usr_web",
"telegramId": null,
"entraObjectId": null,
"roles": {
"web-editor": {
"validUntil": null
Expand Down
4 changes: 4 additions & 0 deletions src/contract/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, { validUntil: string | null }>
/** Fully expanded keys; `validUntil: null` never expires. */
Expand Down
14 changes: 13 additions & 1 deletion src/snapshot/access-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number | null>
/** Permission key → expiry in epoch milliseconds; `null` never expires. */
Expand Down Expand Up @@ -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<string, AccessSubject>()
const byTelegramId = new Map<string, AccessSubject>()
const entraObjectIds = new Set<string>()
for (const raw of body.subjects) {
const subject: AccessSubject = Object.freeze({
sub: raw.sub,
telegramId: raw.telegramId,
entraObjectId: raw.entraObjectId ?? null,
Comment thread
toto04 marked this conversation as resolved.
Resolved
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}`)
Expand Down
44 changes: 42 additions & 2 deletions tests/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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()
Expand Down
Loading