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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ Dynamic registration is disabled. Client-credentials grants are available when b

### 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, their individual expiry times, and linked Telegram IDs. 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, 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.

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.

Expand Down
22 changes: 22 additions & 0 deletions docs/idp-telegram-integration-rfc-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,10 +277,27 @@ If-None-Match: "sha256-9f1c…"
},
"polimi-email": { "health": "ok", "observedAt": "2026-10-07T10:15:02Z" }
},
"catalog": {
"roles": [
{ "key": "admin", "name": "Admin" },
{ "key": "moderator", "name": "Moderator" },
{ "key": "socio", "name": "Socio" }
],
"permissions": [
{ "key": "admin:access", "name": "Open the admin dashboard" },
{ "key": "tg:grants:manage", "name": "Manage Telegram grants" },
{ "key": "tg:moderate", "name": "Moderate groups" },
{ "key": "tg:moderate:global", "name": "Moderate every group" }
]
},
"subjects": [
{
"sub": "usr_8f2a",
"telegramId": "123456789",
"roles": {
"moderator": { "validUntil": null },
"socio": { "validUntil": "2026-10-07T11:14:31Z" }
},
"permissions": {
"tg:moderate": { "validUntil": null },
"tg:moderate:global": { "validUntil": "2026-10-07T11:14:31Z" }
Expand All @@ -289,6 +306,7 @@ If-None-Match: "sha256-9f1c…"
{
"sub": "usr_luca",
"telegramId": null,
"roles": { "admin": { "validUntil": null } },
"permissions": {
"admin:access": { "validUntil": null },
"tg:grants:manage": { "validUntil": null }
Expand All @@ -311,6 +329,10 @@ If-None-Match: "sha256-9f1c…"

- The backend drops a permission locally when `now ≥ validUntil`, even if no new snapshot arrives. This one mechanism handles both student expiry and Entra staleness. A permanent right is never removed because an unrelated path expired.

**`roles` lists the subject's effective roles** with the same expiry rule: every role granted through a path above plus every role it inherits from, each with the latest validity among the paths that reach it. It does not change which subjects are included.

**`catalog` names every role and every permission the projection carries,** sorted by key, so a consumer can offer them in a picker without asking the IdP.

**`telegramId` comes from the linked Telegram account,** not from the evidence row's validity.

- The evidence row's `valid_until` is the Telegram ID token's `exp`. It must not make the link expire.
Expand Down
7 changes: 7 additions & 0 deletions src/auth/access-snapshot.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,17 @@ describe.skipIf(!baseURL || !databaseURL || !clientId || !audience)(
expect(body).toMatchObject({
schema: "polinetwork.access-snapshot/v1",
projection: "backend",
catalog: {
roles: expect.arrayContaining([
{ key: "snapshot-integration-role", name: "Snapshot test" },
]),
permissions: expect.arrayContaining([{ key: "tg:snapshot:test", name: "Snapshot test" }]),
},
subjects: expect.arrayContaining([
{
sub: "snapshot-integration-user",
telegramId: "987654321",
roles: { "snapshot-integration-role": { validUntil: null } },
permissions: { "tg:snapshot:test": { validUntil: null } },
},
]),
Expand Down
107 changes: 103 additions & 4 deletions src/auth/access-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ vi.mock("../db", () => ({ db: {} }));
vi.mock("./membership", () => ({ listEntraGroupMembers: vi.fn() }));
vi.mock("./rbac-store", () => ({ readCatalog: vi.fn() }));

import { projectBackendSubjects } from "./access-snapshot";
import { projectBackendCatalog, projectBackendSubjects } from "./access-snapshot";

const permission = (key: string, implies: string[] = []): PermissionSummary => ({
id: key,
Expand All @@ -21,15 +21,20 @@ const permission = (key: string, implies: string[] = []): PermissionSummary => (
createdAt: null,
updatedAt: null,
});
const role = (key: string, permissions: string[], managed = false): RoleSummary => ({
const role = (
key: string,
permissions: string[],
managed = false,
parents: string[] = [],
): RoleSummary => ({
id: key,
key,
name: key,
description: null,
managed,
sourceState: managed ? key : null,
permissions,
parents: [],
parents,
memberCount: 0,
createdAt: null,
updatedAt: null,
Expand Down Expand Up @@ -105,11 +110,16 @@ describe("backend access projection", () => {
{
sub: "entra-user",
telegramId: null,
roles: { socio: { validUntil: "2026-10-08T10:30:00.000Z" } },
permissions: { "tg:read": { validUntil: "2026-10-08T10:30:00.000Z" } },
},
{
sub: "manual-user",
telegramId: "123456",
roles: {
manual: { validUntil: null },
socio: { validUntil: "2026-10-08T10:30:00.000Z" },
},
permissions: {
"tg:moderate": { validUntil: null },
"tg:read": { validUntil: null },
Expand All @@ -118,6 +128,7 @@ describe("backend access projection", () => {
{
sub: "root",
telegramId: null,
roles: { "master-admin": { validUntil: null } },
permissions: {
"admin:access": { validUntil: null },
"tg:moderate": { validUntil: null },
Expand All @@ -127,6 +138,7 @@ describe("backend access projection", () => {
{
sub: "student-user",
telegramId: null,
roles: { student: { validUntil: "2026-10-08T10:30:00.000Z" } },
permissions: {
"tg:moderate": { validUntil: "2026-10-08T10:30:00.000Z" },
"tg:read": { validUntil: "2026-10-08T10:30:00.000Z" },
Expand Down Expand Up @@ -170,7 +182,94 @@ describe("backend access projection", () => {
now,
});
expect(subjects).toEqual([
{ sub: "signer", telegramId: null, permissions: { "esign:sign": { validUntil: null } } },
{
sub: "signer",
telegramId: null,
roles: { secretary: { validUntil: null } },
permissions: { "esign:sign": { validUntil: null } },
},
]);
});

it("carries inherited roles with the latest expiry among the paths that grant them", () => {
const studentProof = (userId: string, validUntil: string) => ({
userId,
providerId: "polimi-email",
issuer: "https://mail.polimi.it",
externalId: `${userId}@mail.polimi.it`,
states: ["student"],
validUntil: new Date(validUntil),
telegramId: null,
});
const subjects = projectBackendSubjects({
users: ["both", "board", "expired", "legacy"],
manualRoles: [
{ userId: "board", roleKey: "board" },
{ userId: "legacy", roleKey: "legacy" },
],
proofs: [
pnProof("both", "b"),
studentProof("both", "2026-10-08T11:00:00.000Z"),
studentProof("expired", "2026-10-08T09:59:59.000Z"),
],
catalog: {
permissions: [permission("tg:read"), permission("tg:moderate")],
roles: [
role("member", ["tg:read"]),
role("board", ["tg:moderate"], false, ["member"]),
role("legacy", ["tg:read"], false, ["master-admin"]),
role("socio", [], true, ["member"]),
role("student", [], true, ["member"]),
role("master-admin", [], true),
],
},
groups,
observations: [
{
source: "entra:soci",
groupId: "soci-group",
members: ["b"],
observedAt: new Date("2026-10-08T09:30:00.000Z"),
},
],
now,
});
expect(subjects.map(({ sub, roles }) => ({ sub, roles }))).toEqual([
{ sub: "board", roles: { board: { validUntil: null }, member: { validUntil: null } } },
{
sub: "both",
roles: {
member: { validUntil: "2026-10-08T11:00:00.000Z" },
socio: { validUntil: "2026-10-08T10:30:00.000Z" },
student: { validUntil: "2026-10-08T11:00:00.000Z" },
},
},
{ sub: "legacy", roles: { legacy: { validUntil: null } } },
]);
});
});

describe("backend access catalog", () => {
it("names every role and only the permissions the projection carries, by key", () => {
expect(
projectBackendCatalog({
...catalog,
permissions: catalog.permissions.map((entry) =>
entry.key === "tg:read" ? { ...entry, name: "Read groups" } : entry,
),
}),
).toEqual({
roles: [
{ key: "manual", name: "manual" },
{ key: "master-admin", name: "master-admin" },
{ key: "socio", name: "socio" },
{ key: "student", name: "student" },
],
permissions: [
{ key: "admin:access", name: "admin:access" },
{ key: "tg:moderate", name: "tg:moderate" },
{ key: "tg:read", name: "Read groups" },
],
});
});
});
91 changes: 66 additions & 25 deletions src/auth/access-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { authorizationMutationLock } from "../db/security-lock";
import { env } from "../env";
import { listEntraGroupMembers } from "./membership";
import { effectiveRolePermissions, isManagedPermissionKey, type RbacCatalog } from "./rbac";
import { isManagedPermissionKey, resolveAccess, type RbacCatalog } from "./rbac";
import { readCatalog } from "./rbac-store";

const ENTRA_GRACE_MS = 3_600_000;
Expand All @@ -29,14 +29,21 @@ export type AccessSource = {
export type AccessSubject = {
sub: string;
telegramId: string | null;
/** Effective roles: those granted plus every role they inherit from. */
roles: Record<string, { validUntil: string | null }>;
permissions: Record<string, { validUntil: string | null }>;
};
export type AccessCatalog = {
roles: { key: string; name: string }[];
permissions: { key: string; name: string }[];
};
export type AccessSnapshot = {
schema: typeof SCHEMA;
projection: "backend";
generation: number;
builtAt: string;
sources: Record<string, AccessSource>;
catalog: AccessCatalog;
subjects: AccessSubject[];
};

Expand Down Expand Up @@ -119,9 +126,34 @@ function refreshObservations(groups: Group[]): Promise<Set<string>> {
return refreshing;
}

/** Keeps the latest expiry among the paths that grant a key, where null never expires. */
function extend(target: Map<string, string | null>, key: string, expiry: string | null) {
const previous = target.get(key);
if (previous === null) return;
if (previous === undefined || expiry === null || expiry > previous) target.set(key, expiry);
}

function byKey(held: Map<string, string | null>): Record<string, { validUntil: string | null }> {
return Object.fromEntries(
[...held.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, validUntil]) => [key, { validUntil }]),
);
}

/** 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 }[]) =>
entries.map(({ key, name }) => ({ key, name })).sort((a, b) => a.key.localeCompare(b.key));
return {
roles: named(catalog.roles),
permissions: named(catalog.permissions.filter((entry) => !isManagedPermissionKey(entry.key))),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}

/**
* Builds only the backend's reviewed projection: every permission except the identity provider's
* own. Every path carries its own expiry.
* own, and every effective role. Every path carries its own expiry.
*/
export function projectBackendSubjects(input: {
users: string[];
Expand All @@ -133,26 +165,28 @@ export function projectBackendSubjects(input: {
now: Date;
}): AccessSubject[] {
const { users, manualRoles, proofs, catalog, groups, observations, now } = input;
const permissions = new Map<string, Map<string, string | null>>(
users.map((id) => [id, new Map()]),
const held = new Map(
users.map((id) => [
id,
{ roles: new Map<string, string | null>(), permissions: new Map<string, string | null>() },
]),
);
const rolePermissions = new Map<string, string[]>();
const conferred = new Map<string, { roles: string[]; permissions: string[] }>();
const grant = (userId: string, roleKey: string, validUntil: Date | null) => {
const target = permissions.get(userId);
const target = held.get(userId);
if (!target || (validUntil && validUntil <= now)) return;
let keys = rolePermissions.get(roleKey);
if (!keys) {
keys = effectiveRolePermissions(catalog, roleKey).filter(
(key) => !isManagedPermissionKey(key),
);
rolePermissions.set(roleKey, keys);
let access = conferred.get(roleKey);
if (!access) {
const resolved = resolveAccess(catalog, [roleKey]);
access = {
roles: resolved.roles,
permissions: resolved.permissions.filter((key) => !isManagedPermissionKey(key)),
};
conferred.set(roleKey, access);
}
const expiry = validUntil?.toISOString() ?? null;
for (const key of keys) {
const previous = target.get(key);
if (previous === null) continue;
if (previous === undefined || expiry === null || expiry > previous) target.set(key, expiry);
}
for (const key of access.roles) extend(target.roles, key, expiry);
for (const key of access.permissions) extend(target.permissions, key, expiry);
};

for (const row of manualRoles) grant(row.userId, row.roleKey, null);
Expand Down Expand Up @@ -205,20 +239,17 @@ export function projectBackendSubjects(input: {

return users
.flatMap((sub): AccessSubject[] => {
const held = permissions.get(sub);
if (!held?.size) return [];
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,
permissions: Object.fromEntries(
[...held.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, validUntil]) => [key, { validUntil }]),
),
roles: byKey(access.roles),
permissions: byKey(access.permissions),
},
];
})
Expand Down Expand Up @@ -281,8 +312,17 @@ export async function buildBackendSnapshot(): Promise<string> {
observations,
now,
});
const names = projectBackendCatalog(catalog);
const fingerprint = createHash("sha256")
.update(JSON.stringify({ schema: SCHEMA, projection: "backend", sources, subjects }))
.update(
JSON.stringify({
schema: SCHEMA,
projection: "backend",
sources,
catalog: names,
subjects,
}),
)
.digest("hex");
const [previous] = await transaction
.select()
Expand All @@ -300,6 +340,7 @@ export async function buildBackendSnapshot(): Promise<string> {
...sources,
"polimi-email": { health: "ok", observedAt: builtAt },
},
catalog: names,
subjects,
};
const serialized = JSON.stringify(body);
Expand Down
Loading