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
7 changes: 5 additions & 2 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ The executable form of this contract is the `@polinetwork/auth-kit/contract` ent
### 2.1 Content

- **Permissions are fully expanded.** Roles are resolved up through their parents and permissions down through their implications, transitively, in the IdP. The SDK only ever does a key lookup and repeats no role logic.
- **Master Admin is written out, never `*`.** Its entry lists every catalog key under the projection's prefixes. Break-glass admins get `validUntil: null`; members of the Entra admin group get that group's `observedAt` + 1 h.
- **Master Admin is written out, never `*`.** Its entry lists every permission key the projection carries. Break-glass admins get `validUntil: null`; members of the Entra admin group get that group's `observedAt` + 1 h.
- **Validity per key:** an implied key inherits the validity of the path that granted it. When several paths grant a key, the latest expiry wins and `null` beats any date.
- **Keys outside the projection's prefixes never appear,** even when implied by a key that does.
- **The `backend` projection carries every permission except the IdP's own `idp:*` ones,** which never appear, even when implied by a key that does.
- **`roles` lists the effective roles** under the same expiry rules: every granted role plus every role it inherits from through its parents. A subject is still included only for its permissions. The SDK reads roles as given (`hasRole`, `roles`) and repeats no hierarchy logic.
- **`catalog` names every role and every projected permission** (`{ key, name }`, sorted by key), for pickers. It is display data only: a missing or malformed `catalog` gives two empty lists, a malformed `roles` or `permissions` list empties only that list, and the snapshot is still accepted.
- **IdPs before roles were projected** send neither `roles` nor `catalog`. Those snapshots, including persisted ones, still parse, with no roles and an empty catalog.
- **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*$`.
Expand Down
141 changes: 141 additions & 0 deletions fixtures/snapshot-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,125 @@
"observedAt": "2026-10-09T10:15:02.123Z"
}
},
"catalog": {
"roles": [
{
"key": "auditor",
"name": "Auditor"
},
{
"key": "direttivo",
"name": "Direttivo"
},
{
"key": "master-admin",
"name": "Master Admin"
},
{
"key": "moderator",
"name": "Moderator"
},
{
"key": "socio",
"name": "Socio"
},
{
"key": "student",
"name": "Student"
},
{
"key": "web-editor",
"name": "Web editor"
}
],
"permissions": [
{
"key": "admin:access",
"name": "Admin access"
},
{
"key": "azure:members:create",
"name": "Azure members create"
},
{
"key": "groups:labels:write",
"name": "Groups labels write"
},
{
"key": "tg:audit:read",
"name": "Tg audit read"
},
{
"key": "tg:bot:add",
"name": "Tg bot add"
},
{
"key": "tg:grants:manage",
"name": "Tg grants manage"
},
{
"key": "tg:grants:read",
"name": "Tg grants read"
},
{
"key": "tg:groups:manage",
"name": "Tg groups manage"
},
{
"key": "tg:immune",
"name": "Tg immune"
},
{
"key": "tg:messages:delete",
"name": "Tg messages delete"
},
{
"key": "tg:messages:pin",
"name": "Tg messages pin"
},
{
"key": "tg:messages:read",
"name": "Tg messages read"
},
{
"key": "tg:moderate",
"name": "Tg moderate"
},
{
"key": "tg:moderate:global",
"name": "Tg moderate global"
},
{
"key": "tg:trusted",
"name": "Tg trusted"
},
{
"key": "tg:users:read",
"name": "Tg users read"
},
{
"key": "wa:groups:manage",
"name": "Wa groups manage"
},
{
"key": "web:content:write",
"name": "Web content write"
},
{
"key": "web:reports:manage",
"name": "Web reports manage"
}
]
},
"subjects": [
{
"sub": "usr_admin_breakglass",
"telegramId": "100000001",
"roles": {
"master-admin": {
"validUntil": null
}
},
"permissions": {
"admin:access": {
"validUntil": null
Expand Down Expand Up @@ -92,6 +207,11 @@
{
"sub": "usr_admin_entra",
"telegramId": null,
"roles": {
"master-admin": {
"validUntil": "2026-10-09T11:14:58.001Z"
}
},
"permissions": {
"admin:access": {
"validUntil": "2026-10-09T11:14:58.001Z"
Expand Down Expand Up @@ -155,6 +275,17 @@
{
"sub": "usr_direttivo",
"telegramId": "555",
"roles": {
"auditor": {
"validUntil": null
},
"direttivo": {
"validUntil": "2026-10-09T11:14:31.456Z"
},
"socio": {
"validUntil": "2026-10-09T11:14:31.456Z"
}
},
"permissions": {
"admin:access": {
"validUntil": "2026-10-09T11:14:31.456Z"
Expand All @@ -179,6 +310,11 @@
{
"sub": "usr_moderator",
"telegramId": "123456789",
"roles": {
"moderator": {
"validUntil": null
}
},
"permissions": {
"tg:messages:delete": {
"validUntil": null
Expand All @@ -197,6 +333,11 @@
{
"sub": "usr_web",
"telegramId": null,
"roles": {
"web-editor": {
"validUntil": null
}
},
"permissions": {
"admin:access": {
"validUntil": null
Expand Down
1 change: 1 addition & 0 deletions src/contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
} from "./constants"
export { type AccessChangedEventClaims, accessChangedEventClaims, accessChangedEventSchema } from "./event"
export {
type AccessCatalogV1,
type AccessSnapshotBody,
type AccessSnapshotV1,
type AccessSourceV1,
Expand Down
23 changes: 22 additions & 1 deletion src/contract/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ const timestamp = z.iso.datetime({ offset: true })
/** Major version 1 only; minor revisions add fields, which are ignored. */
const schemaId = z.string().regex(/^polinetwork\.access-snapshot\/v1(\.\d+)?$/, "unsupported snapshot schema")

const expiries = z.record(z.string().min(1), z.object({ validUntil: timestamp.nullable() }))

// Display names only, so a malformed list is dropped rather than failing the snapshot.
const names = z
.array(z.object({ key: z.string().min(1), name: z.string() }))
.optional()
.catch(undefined)

/**
* Consumer schema for `polinetwork.access-snapshot/v1`. Deliberately lenient: source keys,
* health values and error codes are open sets, and unknown fields are ignored, so the IdP can
Expand All @@ -25,14 +33,18 @@ export const accessSnapshotSchema = z.object({
error: z.string().optional(),
})
),
/** Absent from IdPs before roles were projected. */
catalog: z.object({ roles: names, permissions: names }).optional().catch(undefined),
subjects: z.array(
z.object({
sub: z.string().min(1),
telegramId: z
.string()
.regex(/^[1-9]\d*$/)
.nullable(),
permissions: z.record(z.string().min(1), z.object({ validUntil: timestamp.nullable() })),
/** Absent from IdPs before roles were projected. */
roles: expiries.optional(),
permissions: expiries,
})
),
})
Expand All @@ -54,15 +66,24 @@ export type AccessSubjectV1 = {
sub: string
/** Decimal string; null when not linked or ambiguous. */
telegramId: 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. */
permissions: Record<string, { validUntil: string | null }>
}

/** Sorted by key. Every role, and every permission the projection can carry. */
export type AccessCatalogV1 = {
roles: { key: string; name: string }[]
permissions: { key: string; name: string }[]
}

export type AccessSnapshotV1 = {
schema: typeof SNAPSHOT_SCHEMA
projection: string
generation: number
builtAt: string
sources: Record<string, AccessSourceV1>
catalog: AccessCatalogV1
subjects: AccessSubjectV1[]
}
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ export {
type KeyStoreStatus,
} from "./key-store"
export { memoryPersistence, type Persistence } from "./persistence"
export { AccessIndex, type AccessSubject } from "./snapshot/access-index"
export {
type AccessCatalog,
type AccessCatalogEntry,
AccessIndex,
type AccessSubject,
} from "./snapshot/access-index"
export {
type AccessSnapshotClient,
type AccessSnapshotOptions,
Expand Down
69 changes: 58 additions & 11 deletions src/snapshot/access-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,44 @@ import { AuthKitError } from "../errors"
export type AccessSubject = Readonly<{
sub: string
telegramId: 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. */
permissions: ReadonlyMap<string, number | null>
}>

export type AccessCatalogEntry = Readonly<{ key: string; name: string }>

/** Names to show in a picker; empty lists when the IdP does not send them. */
export type AccessCatalog = Readonly<{
roles: readonly AccessCatalogEntry[]
permissions: readonly AccessCatalogEntry[]
}>

type Expiries = Record<string, { validUntil: string | null }>

function epochs(entries: Expiries = {}): Map<string, number | null> {
return new Map(
Object.entries(entries).map(([key, { validUntil }]) => [key, validUntil === null ? null : Date.parse(validUntil)])
)
}

function entries(list: readonly AccessCatalogEntry[] = []): readonly AccessCatalogEntry[] {
return Object.freeze(list.map(({ key, name }) => Object.freeze({ key, name })))
}

/** A key is dropped once `now ≥ validUntil`, whatever the sync state (RFC §5.3). */
function holds(expiries: ReadonlyMap<string, number | null> | undefined, key: string, now: number): boolean {
const validUntil = expiries?.get(key)
if (validUntil === undefined) return false
return validUntil === null || now < validUntil
}

function valid(expiries: ReadonlyMap<string, number | null> | undefined, now: number): string[] {
if (!expiries) return []
return [...expiries.keys()].filter((key) => holds(expiries, key, now)).sort()
}

/**
* One validated snapshot, indexed by `sub` and Telegram ID. Immutable, so a client swaps it in
* with a single assignment and readers never see a half-applied snapshot.
Expand All @@ -20,6 +54,7 @@ export class AccessIndex {
readonly generation: number
readonly builtAt: string
readonly sources: AccessSnapshotBody["sources"]
readonly #catalog: AccessCatalog
readonly #bySub: ReadonlyMap<string, AccessSubject>
readonly #byTelegramId: ReadonlyMap<string, AccessSubject>

Expand All @@ -32,6 +67,10 @@ export class AccessIndex {
this.generation = body.generation
this.builtAt = body.builtAt
this.sources = body.sources
this.#catalog = Object.freeze({
roles: entries(body.catalog?.roles),
permissions: entries(body.catalog?.permissions),
})
this.#bySub = bySub
this.#byTelegramId = byTelegramId
}
Expand All @@ -44,12 +83,8 @@ export class AccessIndex {
const subject: AccessSubject = Object.freeze({
sub: raw.sub,
telegramId: raw.telegramId,
permissions: new Map(
Object.entries(raw.permissions).map(([key, { validUntil }]) => [
key,
validUntil === null ? null : Date.parse(validUntil),
])
),
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)
Expand Down Expand Up @@ -78,14 +113,26 @@ export class AccessIndex {
* `now ≥ validUntil`, whatever the sync state (RFC §5.3). Staleness is the client's concern.
*/
has(subject: AccessSubject | undefined, permission: string, now = Date.now()): boolean {
const validUntil = subject?.permissions.get(permission)
if (validUntil === undefined) return false
return validUntil === null || now < validUntil
return holds(subject?.permissions, permission, now)
}

/** The subject's permissions that are still valid at `now`, sorted. */
permissions(subject: AccessSubject | undefined, now = Date.now()): string[] {
if (!subject) return []
return [...subject.permissions.keys()].filter((key) => this.has(subject, key, now)).sort()
return valid(subject?.permissions, now)
}

/** Whether the subject holds the role, directly or by inheritance, at `now`. Same expiry rule as `has`. */
hasRole(subject: AccessSubject | undefined, role: string, now = Date.now()): boolean {
return holds(subject?.roles, role, now)
}

/** The subject's effective roles that are still valid at `now`, sorted. */
roles(subject: AccessSubject | undefined, now = Date.now()): string[] {
return valid(subject?.roles, now)
}

/** Every role, and every permission the projection can carry, with display names. */
catalog(): AccessCatalog {
return this.#catalog
}
}
Loading
Loading