From ce5dd3eee5ceaf65b6dd53a1e65c32fb4d8bada9 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Mon, 2 Nov 2026 19:53:59 +0100 Subject: [PATCH 1/3] feat(roles): add Presidente and Segretario built-in roles Both are managed roles proven by direct membership of an Entra group (PN_ENTRA_PRESIDENTE_GROUP_ID, PN_ENTRA_SEGRETARIO_GROUP_ID), like Direttivo, and always inherit from Direttivo. Migration 0024 seeds them and the inheritance edge. --- .env.example | 5 + README.md | 17 +- drizzle/0024_presidente_segretario_roles.sql | 22 + drizzle/meta/0024_snapshot.json | 2486 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + scripts/security-config.mjs | 6 +- src/auth/access-snapshot.ts | 14 + src/auth/account-overview.ts | 4 +- src/auth/identity-subject.ts | 2 + src/auth/membership.ts | 2 + src/auth/policy.ts | 2 + src/auth/rbac.test.ts | 49 +- src/auth/rbac.ts | 43 +- src/db/rbac.ts | 2 +- src/env.ts | 4 + 15 files changed, 2647 insertions(+), 18 deletions(-) create mode 100644 drizzle/0024_presidente_segretario_roles.sql create mode 100644 drizzle/meta/0024_snapshot.json diff --git a/.env.example b/.env.example index eacf9cc..f8e94ca 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,11 @@ PN_ENTRA_MEMBER_GROUP_ID=1c68dbb8-4ac3-4569-a886-283b5a825cbd # Microsoft Entra security group whose direct members hold the built-in Direttivo role. # Unset: nobody is inferred as Direttivo. # PN_ENTRA_DIRETTIVO_GROUP_ID= +# Microsoft Entra security groups whose direct members hold the built-in Presidente and +# Segretario roles, which inherit everything Direttivo grants. +# Unset: nobody is inferred as holding that office. +# PN_ENTRA_PRESIDENTE_GROUP_ID= +# PN_ENTRA_SEGRETARIO_GROUP_ID= # Lifetime of persisted sign-in evidence. Authorization independently rechecks Graph # using a fixed maximum 60-second cache; this setting cannot extend RBAC access. PN_ENTRA_MEMBER_REFRESH_HOURS=24 diff --git a/README.md b/README.md index 627ce94..de854ab 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ The environment changes are: | Setting | RBAC behavior | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `PN_ENTRA_DIRETTIVO_GROUP_ID` | New, optional group object ID for Direttivo. Unset grants nobody that evidence-backed role. | +| `PN_ENTRA_PRESIDENTE_GROUP_ID` | New, optional group object ID for Presidente. Unset grants nobody that evidence-backed role. | +| `PN_ENTRA_SEGRETARIO_GROUP_ID` | New, optional group object ID for Segretario. Unset grants nobody that evidence-backed role. | | `PN_ENTRA_OIDC_ADMIN_GROUP_ID` | Existing setting now grants Master Admin. Without it, PN accounts are not administrators. Requires complete PN tenant/client credentials when set. | | `IDP_ADMIN_USER_IDS` | Existing comma-separated local user IDs remain the explicit break-glass administrators. Configure at least this or the admin group before startup. | | `PN_ENTRA_MEMBER_REFRESH_HOURS` | Still controls persisted sign-in evidence, defaults to 24. It no longer determines authorization freshness. | @@ -112,7 +114,7 @@ inherit from each other or two permissions grant each other. ### Roles the identity provider defines itself -Four roles always exist and are never created, deleted, or handed out by an administrator. +Six roles always exist and are never created, deleted, or handed out by an administrator. Their membership is conferred by the identity provider itself: | Role | Key | Granted by | @@ -120,27 +122,34 @@ Their membership is conferred by the identity provider itself: | `Master Admin` | `master-admin` | `IDP_ADMIN_USER_IDS`; otherwise the configured administrators group, never an unconfigured fallback | | `Socio` | `socio` | Direct membership of the `Soci` group in PoliNetwork Entra ID | | `Direttivo` | `direttivo` | Direct membership of `PN_ENTRA_DIRETTIVO_GROUP_ID` in PoliNetwork Entra ID | +| `Presidente` | `presidente` | Direct membership of `PN_ENTRA_PRESIDENTE_GROUP_ID` in PoliNetwork Entra ID | +| `Segretario` | `segretario` | Direct membership of `PN_ENTRA_SEGRETARIO_GROUP_ID` in PoliNetwork Entra ID | | `Student` | `student` | A verification code delivered to an `@mail.polimi.it` address | **Master Admin holds every permission that exists**, including ones created after it was last looked at, because it is a wildcard rather than a stored list. It therefore has no grant list of its own to edit, and no role may inherit from it: that would launder a wildcard nobody can be given into a role an administrator could hand to anyone. -Unlike the other three it is not proven by identity evidence and never appears among the +Unlike the others it is not proven by identity evidence and never appears among the `states`: it comes from the deployment's own configuration, which is what keeps the service from being locked out of its own administration. `IDP_ADMIN_USER_IDS` is always honored. Set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to limit everyone else to that Microsoft Entra group. If the group is unset, only the explicit allowlist can confer Master Admin. Startup fails without either an admin group plus complete PN Entra credentials or a nonempty allowlist. -What the other three grant is still yours to choose: give them permissions, rename them, +What the others grant is still yours to choose: give them permissions, rename them, describe them, and place them in the hierarchy like any other role. Only their key, their deletion, and who holds them are fixed. The checked-in migrations seed them alongside the two permissions this service already issued, so existing consumers keep working: `socio` grants `membership:read` and `student` grants `student:verified`. +`Presidente` and `Segretario` are the board's offices, so they always inherit from +`Direttivo`: whoever holds one gets everything Direttivo grants, and the editor refuses to +remove that parent. Give them anything extra the office needs on top. + `PN_ENTRA_DIRETTIVO_GROUP_ID` is optional and has no default. Until you set it to the -board's Entra group object ID, nobody is inferred as Direttivo. Both group checks reuse the +board's Entra group object ID, nobody is inferred as Direttivo. The same goes for +`PN_ENTRA_PRESIDENTE_GROUP_ID` and `PN_ENTRA_SEGRETARIO_GROUP_ID`. All group checks reuse the `PN_ENTRA_*` Graph credentials. Authorization rechecks membership with a fixed 60-second cache measured from lookup start. Stored sign-in evidence and `PN_ENTRA_MEMBER_REFRESH_HOURS` do not extend authorization. Failed or overlong checks grant nothing. diff --git a/drizzle/0024_presidente_segretario_roles.sql b/drizzle/0024_presidente_segretario_roles.sql new file mode 100644 index 0000000..aa6bb7d --- /dev/null +++ b/drizzle/0024_presidente_segretario_roles.sql @@ -0,0 +1,22 @@ +-- A hand-made role already using one of these keys would silently stand in for the board +-- office, keeping its hand assignments. Stop instead, so an administrator renames it first. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM "role" WHERE "key" IN ('presidente', 'segretario') AND NOT "managed") THEN + RAISE EXCEPTION 'Rename the existing presidente or segretario role before migrating: those keys now belong to built-in roles.'; + END IF; +END $$;--> statement-breakpoint +-- The President and Secretary of the board. Like Direttivo, membership follows direct +-- membership of an Entra group (PN_ENTRA_PRESIDENTE_GROUP_ID, PN_ENTRA_SEGRETARIO_GROUP_ID), +-- so the application never writes user_role rows for them. +INSERT INTO "role" ("id", "key", "name", "description", "managed", "source_state") VALUES + ('static-role-presidente', 'presidente', 'Presidente', 'President of the PoliNetwork APS board.', true, 'presidente'), + ('static-role-segretario', 'segretario', 'Segretario', 'Secretary of the PoliNetwork APS board.', true, 'segretario') +ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint +-- Both offices sit on the board, so they always inherit everything Direttivo grants. +INSERT INTO "role_parent" ("role_id", "parent_role_id") +SELECT office.id, board.id +FROM "role" office +JOIN "role" board ON board.key = 'direttivo' +WHERE office.key IN ('presidente', 'segretario') +ON CONFLICT DO NOTHING; diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json new file mode 100644 index 0000000..4672c6e --- /dev/null +++ b/drizzle/meta/0024_snapshot.json @@ -0,0 +1,2486 @@ +{ + "id": "c5b733e5-4e43-4396-9b79-72afe4affd43", + "prevId": "6e6dc4d1-22d0-44ab-a009-fd1f763786a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "account_issuer_subject_uidx": { + "name": "account_issuer_subject_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthAccessToken_authorizationCodeId_idx": { + "name": "oauthAccessToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "columnsFrom": ["client_id"], + "tableTo": "oauth_client", + "columnsTo": ["client_id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "columnsFrom": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "columnsFrom": ["refresh_id"], + "tableTo": "oauth_refresh_token", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "columns": ["token"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_dark": { + "name": "icon_dark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "columns": ["client_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClientResource_clientId_resourceId_uidx": { + "name": "oauthClientResource_clientId_resourceId_uidx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthClientResource_clientId_idx": { + "name": "oauthClientResource_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthClientResource_resourceId_idx": { + "name": "oauthClientResource_resourceId_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "columnsFrom": ["client_id"], + "tableTo": "oauth_client", + "columnsTo": ["client_id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "columnsFrom": ["resource_id"], + "tableTo": "oauth_resource", + "columnsTo": ["identifier"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "columnsFrom": ["client_id"], + "tableTo": "oauth_client", + "columnsTo": ["client_id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "oauthRefreshToken_authorizationCodeId_idx": { + "name": "oauthRefreshToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "columnsFrom": ["client_id"], + "tableTo": "oauth_client", + "columnsTo": ["client_id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "columnsFrom": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "columns": ["token"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "columns": ["identifier"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "columns": ["credential_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "columns": ["key"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "passkey_prompt_declined_at": { + "name": "passkey_prompt_declined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_evidence": { + "name": "identity_evidence", + "schema": "", + "columns": { + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "states": { + "name": "states", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "identity_evidence_telegram_id_uidx": { + "name": "identity_evidence_telegram_id_uidx", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"identity_evidence\".\"telegram_id\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "identity_evidence_issuer_subject_pk": { + "name": "identity_evidence_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.student_verification_challenge": { + "name": "student_verification_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "studentVerificationChallenge_email_uidx": { + "name": "studentVerificationChallenge_email_uidx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "studentVerificationChallenge_expiresAt_idx": { + "name": "studentVerificationChallenge_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_verification_challenge_user_id_user_id_fk": { + "name": "student_verification_challenge_user_id_user_id_fk", + "tableFrom": "student_verification_challenge", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission": { + "name": "permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_key_uidx": { + "name": "permission_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_implication": { + "name": "permission_implication", + "schema": "", + "columns": { + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "implied_permission_id": { + "name": "implied_permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "permissionImplication_implied_idx": { + "name": "permissionImplication_implied_idx", + "columns": [ + { + "expression": "implied_permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_implication_permission_id_permission_id_fk": { + "name": "permission_implication_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "columnsFrom": ["permission_id"], + "tableTo": "permission", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_implication_implied_permission_id_permission_id_fk": { + "name": "permission_implication_implied_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "columnsFrom": ["implied_permission_id"], + "tableTo": "permission", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "permission_implication_permission_id_implied_permission_id_pk": { + "name": "permission_implication_permission_id_implied_permission_id_pk", + "columns": ["permission_id", "implied_permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_audit_event": { + "name": "rbac_audit_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_state": { + "name": "source_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "people_binding": { + "name": "people_binding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_key_uidx": { + "name": "role_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "role_people_binding_check": { + "name": "role_people_binding_check", + "value": "\"role\".\"people_binding\" is null or (not \"role\".\"managed\" and \"role\".\"people_binding\" ~ '^(admin|capo|team:[a-z0-9-]{1,32}:(member|lead))$')" + } + }, + "isRLSEnabled": false + }, + "public.role_parent": { + "name": "role_parent", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_role_id": { + "name": "parent_role_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "roleParent_parent_idx": { + "name": "roleParent_parent_idx", + "columns": [ + { + "expression": "parent_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "role_parent_role_id_role_id_fk": { + "name": "role_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "columnsFrom": ["role_id"], + "tableTo": "role", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "role_parent_parent_role_id_role_id_fk": { + "name": "role_parent_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "columnsFrom": ["parent_role_id"], + "tableTo": "role", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "role_parent_role_id_parent_role_id_pk": { + "name": "role_parent_role_id_parent_role_id_pk", + "columns": ["role_id", "parent_role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permission": { + "name": "role_permission", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rolePermission_permission_idx": { + "name": "rolePermission_permission_idx", + "columns": [ + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "columnsFrom": ["role_id"], + "tableTo": "role", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "role_permission_permission_id_permission_id_fk": { + "name": "role_permission_permission_id_permission_id_fk", + "tableFrom": "role_permission", + "columnsFrom": ["permission_id"], + "tableTo": "permission", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "role_permission_role_id_permission_id_pk": { + "name": "role_permission_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_role": { + "name": "user_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "userRole_role_idx": { + "name": "userRole_role_idx", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_role_user_id_user_id_fk": { + "name": "user_role_user_id_user_id_fk", + "tableFrom": "user_role", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_role_role_id_role_id_fk": { + "name": "user_role_role_id_role_id_fk", + "tableFrom": "user_role", + "columnsFrom": ["role_id"], + "tableTo": "role", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "user_role_user_id_role_id_pk": { + "name": "user_role_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_outbox": { + "name": "access_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "name": "access_outbox_id_seq", + "increment": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "startWith": "1", + "cache": "1", + "cycle": false, + "schema": "public", + "type": "always" + } + }, + "projection": { + "name": "projection", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_snapshot_state": { + "name": "access_snapshot_state", + "schema": "", + "columns": { + "projection": { + "name": "projection", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entra_group_observation": { + "name": "entra_group_observation", + "schema": "", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "members": { + "name": "members", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9dc337d..f7d84b7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1792927355527, "tag": "0023_role_people_binding", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1793645548698, + "tag": "0024_presidente_segretario_roles", + "breakpoints": true } ] } diff --git a/scripts/security-config.mjs b/scripts/security-config.mjs index 83e8994..58faaf9 100644 --- a/scripts/security-config.mjs +++ b/scripts/security-config.mjs @@ -37,6 +37,8 @@ const schema = z OAUTH_BACKEND_EVENTS_URL: optional(z.url()), PN_ENTRA_MEMBER_GROUP_ID: optional(z.uuid()), PN_ENTRA_DIRETTIVO_GROUP_ID: optional(z.uuid()), + PN_ENTRA_PRESIDENTE_GROUP_ID: optional(z.uuid()), + PN_ENTRA_SEGRETARIO_GROUP_ID: optional(z.uuid()), PN_ENTRA_MEMBER_REFRESH_HOURS: optional(z.coerce.number().int().positive()), STUDENT_VERIFICATION_TTL_DAYS: optional(z.coerce.number().int().positive()), GOOGLE_CLIENT_ID: optional(z.string().trim().min(1)), @@ -124,7 +126,9 @@ const schema = z if ( (credentials.some(Boolean) || config.PN_ENTRA_OIDC_ADMIN_GROUP_ID || - config.PN_ENTRA_DIRETTIVO_GROUP_ID) && + config.PN_ENTRA_DIRETTIVO_GROUP_ID || + config.PN_ENTRA_PRESIDENTE_GROUP_ID || + config.PN_ENTRA_SEGRETARIO_GROUP_ID) && !credentials.every(Boolean) ) context.addIssue({ diff --git a/src/auth/access-snapshot.ts b/src/auth/access-snapshot.ts index 8f7e60b..4207ff3 100644 --- a/src/auth/access-snapshot.ts +++ b/src/auth/access-snapshot.ts @@ -69,6 +69,20 @@ function configuredGroups(): Group[] { groupId: env.PN_ENTRA_DIRETTIVO_GROUP_ID, } : null, + env.PN_ENTRA_PRESIDENTE_GROUP_ID + ? { + source: "entra:presidente", + roleKey: "presidente", + groupId: env.PN_ENTRA_PRESIDENTE_GROUP_ID, + } + : null, + env.PN_ENTRA_SEGRETARIO_GROUP_ID + ? { + source: "entra:segretario", + roleKey: "segretario", + groupId: env.PN_ENTRA_SEGRETARIO_GROUP_ID, + } + : null, env.PN_ENTRA_OIDC_ADMIN_GROUP_ID ? { source: "entra:admins", diff --git a/src/auth/account-overview.ts b/src/auth/account-overview.ts index 0e28c7e..cdef9fd 100644 --- a/src/auth/account-overview.ts +++ b/src/auth/account-overview.ts @@ -12,8 +12,8 @@ import { isPlaceholderEmail } from "./users"; /** The sign-in accounts whose address the account page shows on its card. */ const EMAIL_PROVIDERS = ["google", "pn-entra"] as const; -/** Socio and Direttivo are groups in PoliNetwork's Entra tenant, so they read as groups. */ -const GROUP_STATES = ["socio", "direttivo"]; +/** Socio, Direttivo and the board offices are groups in PoliNetwork's Entra tenant, so they read as groups. */ +const GROUP_STATES = ["socio", "direttivo", "presidente", "segretario"]; /** * What the account page shows besides the linked accounts and passkeys: the address on each diff --git a/src/auth/identity-subject.ts b/src/auth/identity-subject.ts index 8900b4a..6277fa0 100644 --- a/src/auth/identity-subject.ts +++ b/src/auth/identity-subject.ts @@ -58,6 +58,8 @@ export async function readIdentitySubject( return [ ["socio", env.PN_ENTRA_MEMBER_GROUP_ID], ["direttivo", env.PN_ENTRA_DIRETTIVO_GROUP_ID], + ["presidente", env.PN_ENTRA_PRESIDENTE_GROUP_ID], + ["segretario", env.PN_ENTRA_SEGRETARIO_GROUP_ID], ].map(async ([state, groupId]) => { if (groupId && (await check(groupId, objectId))) verifiedStates.add(state!); }); diff --git a/src/auth/membership.ts b/src/auth/membership.ts index 988758f..2fa65b2 100644 --- a/src/auth/membership.ts +++ b/src/auth/membership.ts @@ -119,6 +119,8 @@ export async function checkPnGroupStates(objectId: string): Promise Boolean(group.groupId)); const memberships = await Promise.all( groups.map((group) => checkEntraGroupMember(group.groupId, objectId)), diff --git a/src/auth/policy.ts b/src/auth/policy.ts index 760de1b..afef8ca 100644 --- a/src/auth/policy.ts +++ b/src/auth/policy.ts @@ -9,6 +9,8 @@ export type IdentityEvidence = { const STATE_PROVIDERS: Record = { socio: "pn-entra", direttivo: "pn-entra", + presidente: "pn-entra", + segretario: "pn-entra", student: "polimi-email", }; diff --git a/src/auth/rbac.test.ts b/src/auth/rbac.test.ts index 91bdc47..f0e32d2 100644 --- a/src/auth/rbac.test.ts +++ b/src/auth/rbac.test.ts @@ -123,18 +123,18 @@ describe("roles the identity provider defines itself", () => { expect(staticRolesForStates(["unrelated"])).toEqual([]); }); - it("covers Master Admin, Socio, Direttivo, and Student", () => { - expect(STATIC_ROLES.map((entry) => entry.key)).toEqual([ - "master-admin", - "socio", - "direttivo", - "student", - ]); - for (const key of ["master-admin", "socio", "direttivo", "student"]) - expect(isStaticRoleKey(key)).toBe(true); + it("covers Master Admin, Socio, Direttivo, the board offices, and Student", () => { + const keys = ["master-admin", "socio", "direttivo", "presidente", "segretario", "student"]; + expect(STATIC_ROLES.map((entry) => entry.key)).toEqual(keys); + for (const key of keys) expect(isStaticRoleKey(key)).toBe(true); expect(isStaticRoleKey("chair")).toBe(false); }); + it("confers the board offices from their own states", () => { + expect(staticRolesForStates(["presidente", "socio"])).toEqual(["socio", "presidente"]); + expect(staticRolesForStates(["segretario"])).toEqual(["segretario"]); + }); + it("refuses a new role that would shadow one of them", () => { const errors = validateRoleDraft( { key: "socio", name: "Socio", description: "", parents: [], permissions: [] }, @@ -144,6 +144,37 @@ describe("roles the identity provider defines itself", () => { }); }); +describe("board offices", () => { + const offices: RbacCatalog = { + permissions: catalog.permissions, + roles: [ + ...catalog.roles, + role("presidente", [], ["direttivo"], true), + role("segretario", [], ["direttivo"], true), + ], + }; + + it("give their holders everything Direttivo confers", () => { + for (const key of ["presidente", "segretario"]) + expect(resolveAccess(offices, [key])).toEqual({ + roles: [key, "direttivo", "socio"].sort(), + permissions: ["membership:read", "membership:write"], + }); + }); + + it("refuse an edit that drops Direttivo as a parent", () => { + const draft = { key: "presidente", name: "Presidente", description: "", permissions: [] }; + const context = { catalog: offices, currentKey: "presidente" }; + expect(validateRoleDraft({ ...draft, parents: [] }, context).parents).toMatch(/direttivo/); + expect( + validateRoleDraft({ ...draft, parents: ["direttivo"] }, context).parents, + ).toBeUndefined(); + expect( + validateRoleDraft({ ...draft, parents: ["direttivo", "student"] }, context).parents, + ).toBeUndefined(); + }); +}); + describe("validation", () => { const context = { catalog }; diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts index 37ff0d8..f7249d3 100644 --- a/src/auth/rbac.ts +++ b/src/auth/rbac.ts @@ -7,7 +7,8 @@ * the role, and is null for a role conferred some other way. * * Administrators can still rename them, describe them, give them permissions, and place - * them in the role hierarchy — only their membership is out of their hands. + * them in the role hierarchy — only their membership is out of their hands. `parents` are + * the roles each one always inherits from, whatever else the administrator adds. */ export const STATIC_ROLES = [ { @@ -15,6 +16,7 @@ export const STATIC_ROLES = [ state: null, /** Holds every permission that exists, including ones created later. */ grantsAllPermissions: true, + parents: [], name: "Master Admin", description: "Complete control of this identity provider.", evidence: @@ -24,6 +26,7 @@ export const STATIC_ROLES = [ key: "socio", state: "socio", grantsAllPermissions: false, + parents: [], name: "Socio", description: "Member of PoliNetwork APS.", evidence: @@ -33,15 +36,39 @@ export const STATIC_ROLES = [ key: "direttivo", state: "direttivo", grantsAllPermissions: false, + parents: [], name: "Direttivo", description: "Member of the PoliNetwork APS board.", evidence: "Direct membership of the Direttivo group in PoliNetwork Entra ID. Membership checks expire after one minute.", }, + { + key: "presidente", + state: "presidente", + grantsAllPermissions: false, + /** The President is on the board, so holds everything Direttivo grants. */ + parents: ["direttivo"], + name: "Presidente", + description: "President of the PoliNetwork APS board.", + evidence: + "Direct membership of the Presidente group in PoliNetwork Entra ID. Membership checks expire after one minute.", + }, + { + key: "segretario", + state: "segretario", + grantsAllPermissions: false, + /** The Secretary is on the board, so holds everything Direttivo grants. */ + parents: ["direttivo"], + name: "Segretario", + description: "Secretary of the PoliNetwork APS board.", + evidence: + "Direct membership of the Segretario group in PoliNetwork Entra ID. Membership checks expire after one minute.", + }, { key: "student", state: "student", grantsAllPermissions: false, + parents: [], name: "Student", description: "Verified Politecnico di Milano student.", evidence: "A verification code delivered to the person's @mail.polimi.it address.", @@ -76,6 +103,11 @@ export function staticRolesForStates(states: readonly string[]): string[] { ); } +/** The roles a built-in role always inherits from. */ +export function intrinsicParents(key: string): readonly string[] { + return staticRole(key)?.parents ?? []; +} + /** * Permissions the identity provider defines itself, covering its own administration. They * cannot be created, deleted, or rekeyed, because the code checks for these exact keys. @@ -525,6 +557,15 @@ export function validateRoleDraft( draft.parents.some((parent) => roleParentWouldCycle(catalog, currentKey, parent)) ) errors.parents = "That would make two roles inherit from each other."; + else { + const kept = currentKey + ? intrinsicParents(currentKey).filter( + (parent) => index.roles.has(parent) && !draft.parents.includes(parent), + ) + : []; + if (kept.length) + errors.parents = `This role always inherits from ${kept.map((parent) => index.roles.get(parent)?.name ?? parent).join(" and ")}.`; + } return errors; } diff --git a/src/db/rbac.ts b/src/db/rbac.ts index 9586b2d..da464e0 100644 --- a/src/db/rbac.ts +++ b/src/db/rbac.ts @@ -54,7 +54,7 @@ export const permissionImplication = pgTable( /** * A named bundle of permissions. * - * `managed` roles (Master Admin, Socio, Direttivo, Student) are seeded by the application + * `managed` roles (Master Admin, Socio, Direttivo, Presidente, Segretario, Student) are seeded by the application * and their membership is conferred by the identity provider: through `source_state` for * the roles proven by identity evidence, and through the configured administrator * allowlist for Master Admin, which has no state. They cannot be created, deleted, diff --git a/src/env.ts b/src/env.ts index e175671..babc8f5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -27,6 +27,10 @@ export const env = createEnv({ // Optional Entra group whose direct members hold the built-in Direttivo role. // Unset: nobody is inferred as Direttivo. PN_ENTRA_DIRETTIVO_GROUP_ID: z.uuid().optional(), + // Optional Entra groups whose direct members hold the built-in Presidente and + // Segretario roles. Unset: nobody is inferred as holding that office. + PN_ENTRA_PRESIDENTE_GROUP_ID: z.uuid().optional(), + PN_ENTRA_SEGRETARIO_GROUP_ID: z.uuid().optional(), PN_ENTRA_MEMBER_REFRESH_HOURS: z.coerce.number().int().positive().default(24), // Optional stricter Entra group whose direct members hold the built-in Master Admin // role, and through it every permission. From 2e7d503598554999435e4fe0bb38bbe03bedc694 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Mon, 2 Nov 2026 20:06:18 +0100 Subject: [PATCH 2/3] feat(roles): make Presidente and Segretario assignable by Master Admin only Drop the Entra group wiring. The two board offices stay built-in (fixed key, cannot be deleted, always inherit from Direttivo), but are given and taken back by hand, and only by a Master Admin. Migration 0024 lets user_role hold these two managed roles. --- .env.example | 5 - README.md | 176 +++++-------------- drizzle/0024_presidente_segretario_roles.sql | 32 +++- scripts/security-config.mjs | 6 +- src/auth/access-snapshot.ts | 18 +- src/auth/account-overview.ts | 8 +- src/auth/identity-subject.ts | 2 - src/auth/mcp-server.ts | 7 +- src/auth/membership.ts | 2 - src/auth/people-roles.ts | 10 +- src/auth/people-search.ts | 5 +- src/auth/policy.ts | 2 - src/auth/rbac-security.integration.test.mjs | 23 +++ src/auth/rbac-store.ts | 54 ++++-- src/auth/rbac.test.ts | 14 +- src/auth/rbac.ts | 45 +++-- src/auth/user-directory.ts | 6 +- src/components/rbac/delegation.test.ts | 19 +- src/components/rbac/delegation.ts | 16 +- src/components/users/user-roles.tsx | 16 +- src/env.ts | 4 - src/routes/access/roles/$roleId.tsx | 17 +- src/routes/users/index.tsx | 4 +- 23 files changed, 256 insertions(+), 235 deletions(-) diff --git a/.env.example b/.env.example index f8e94ca..eacf9cc 100644 --- a/.env.example +++ b/.env.example @@ -35,11 +35,6 @@ PN_ENTRA_MEMBER_GROUP_ID=1c68dbb8-4ac3-4569-a886-283b5a825cbd # Microsoft Entra security group whose direct members hold the built-in Direttivo role. # Unset: nobody is inferred as Direttivo. # PN_ENTRA_DIRETTIVO_GROUP_ID= -# Microsoft Entra security groups whose direct members hold the built-in Presidente and -# Segretario roles, which inherit everything Direttivo grants. -# Unset: nobody is inferred as holding that office. -# PN_ENTRA_PRESIDENTE_GROUP_ID= -# PN_ENTRA_SEGRETARIO_GROUP_ID= # Lifetime of persisted sign-in evidence. Authorization independently rechecks Graph # using a fixed maximum 60-second cache; this setting cannot extend RBAC access. PN_ENTRA_MEMBER_REFRESH_HOURS=24 diff --git a/README.md b/README.md index de854ab..6044223 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PoliNetwork Auth prototype -A standalone TanStack Start and Better Auth identity provider. As of RFC v3 Phase 6 (October 2026), every PoliNetwork service signs people in through this IdP; no applications still use the backend's removed login (see [Backend login removal](#backend-login-removal)). This repository does not migrate production data or grant Telegram moderation access. +A standalone TanStack Start and Better Auth identity provider. The backend remains the active authentication provider for all existing PoliNetwork applications. This repository does not change backend authentication, migrate production data, or grant Telegram moderation access. ## Run locally @@ -65,8 +65,6 @@ The environment changes are: | Setting | RBAC behavior | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `PN_ENTRA_DIRETTIVO_GROUP_ID` | New, optional group object ID for Direttivo. Unset grants nobody that evidence-backed role. | -| `PN_ENTRA_PRESIDENTE_GROUP_ID` | New, optional group object ID for Presidente. Unset grants nobody that evidence-backed role. | -| `PN_ENTRA_SEGRETARIO_GROUP_ID` | New, optional group object ID for Segretario. Unset grants nobody that evidence-backed role. | | `PN_ENTRA_OIDC_ADMIN_GROUP_ID` | Existing setting now grants Master Admin. Without it, PN accounts are not administrators. Requires complete PN tenant/client credentials when set. | | `IDP_ADMIN_USER_IDS` | Existing comma-separated local user IDs remain the explicit break-glass administrators. Configure at least this or the admin group before startup. | | `PN_ENTRA_MEMBER_REFRESH_HOURS` | Still controls persisted sign-in evidence, defaults to 24. It no longer determines authorization freshness. | @@ -114,47 +112,47 @@ inherit from each other or two permissions grant each other. ### Roles the identity provider defines itself -Six roles always exist and are never created, deleted, or handed out by an administrator. -Their membership is conferred by the identity provider itself: +Six roles always exist and are never created or deleted by an administrator. Four of them +are conferred by the identity provider itself and never handed out: | Role | Key | Granted by | | -------------- | -------------- | --------------------------------------------------------------------------------------------------- | | `Master Admin` | `master-admin` | `IDP_ADMIN_USER_IDS`; otherwise the configured administrators group, never an unconfigured fallback | | `Socio` | `socio` | Direct membership of the `Soci` group in PoliNetwork Entra ID | | `Direttivo` | `direttivo` | Direct membership of `PN_ENTRA_DIRETTIVO_GROUP_ID` in PoliNetwork Entra ID | -| `Presidente` | `presidente` | Direct membership of `PN_ENTRA_PRESIDENTE_GROUP_ID` in PoliNetwork Entra ID | -| `Segretario` | `segretario` | Direct membership of `PN_ENTRA_SEGRETARIO_GROUP_ID` in PoliNetwork Entra ID | | `Student` | `student` | A verification code delivered to an `@mail.polimi.it` address | +The other two are the board offices, `Presidente` (`presidente`) and `Segretario` +(`segretario`). They are given and taken back by hand, like a created role, but only by a +Master Admin: holding `idp:roles:assign` is not enough. Both always inherit from +`Direttivo`, so whoever holds one gets everything Direttivo grants, and the editor refuses +to remove that parent. Holding an office does not add the `direttivo` state: that still +comes only from the Entra group. + **Master Admin holds every permission that exists**, including ones created after it was last looked at, because it is a wildcard rather than a stored list. It therefore has no grant list of its own to edit, and no role may inherit from it: that would launder a wildcard nobody can be given into a role an administrator could hand to anyone. -Unlike the others it is not proven by identity evidence and never appears among the +Unlike Socio, Direttivo and Student it is not proven by identity evidence and never appears among the `states`: it comes from the deployment's own configuration, which is what keeps the service from being locked out of its own administration. `IDP_ADMIN_USER_IDS` is always honored. Set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to limit everyone else to that Microsoft Entra group. If the group is unset, only the explicit allowlist can confer Master Admin. Startup fails without either an admin group plus complete PN Entra credentials or a nonempty allowlist. -What the others grant is still yours to choose: give them permissions, rename them, +What the other built-in roles grant is still yours to choose: give them permissions, rename them, describe them, and place them in the hierarchy like any other role. Only their key, their -deletion, and who holds them are fixed. The checked-in migrations seed them alongside the +deletion, and (except for the board offices) who holds them are fixed. The checked-in migrations seed them alongside the two permissions this service already issued, so existing consumers keep working: `socio` grants `membership:read` and `student` grants `student:verified`. -`Presidente` and `Segretario` are the board's offices, so they always inherit from -`Direttivo`: whoever holds one gets everything Direttivo grants, and the editor refuses to -remove that parent. Give them anything extra the office needs on top. - `PN_ENTRA_DIRETTIVO_GROUP_ID` is optional and has no default. Until you set it to the -board's Entra group object ID, nobody is inferred as Direttivo. The same goes for -`PN_ENTRA_PRESIDENTE_GROUP_ID` and `PN_ENTRA_SEGRETARIO_GROUP_ID`. All group checks reuse the +board's Entra group object ID, nobody is inferred as Direttivo. Both group checks reuse the `PN_ENTRA_*` Graph credentials. Authorization rechecks membership with a fixed 60-second cache measured from lookup start. Stored sign-in evidence and `PN_ENTRA_MEMBER_REFRESH_HOURS` do not extend authorization. Failed or overlong checks grant nothing. -Membership of the built-in roles is not a role assignment: nothing is written to +Membership of the evidence-backed built-in roles is not a role assignment: nothing is written to `user_role` for them, and evidence contributes only when joined to an account owned by the user. States accumulate independently, so a socio is not automatically a student. Signature, issuer, audience, expiration, and Entra tenant are checked before evidence is recorded. @@ -167,31 +165,29 @@ when that is what you mean. ### Permissions the identity provider defines itself Administering this service is expressed as permissions like any other capability, so it can -be delegated to a role instead of being wired to a single group. These twelve always exist +be delegated to a role instead of being wired to a single group. These ten always exist and can never be created, deleted, or rekeyed, because the code checks for these exact keys; which roles carry them is entirely up to you. -| Permission | Covers | -| ------------------------ | ----------------------------------------------------------------- | -| `idp:people:read` | Searching the people registered here | -| `idp:users:read` | Browsing the user directory at `/users` | -| `idp:users:delete` | Permanently deleting someone's account | -| `idp:permissions:read` | Seeing permissions in the `/access` section | -| `idp:permissions:write` | Creating, changing, and deleting permissions | -| `idp:roles:read` | Seeing roles, what they grant, and who holds them | -| `idp:roles:write` | Creating, changing, and deleting roles | -| `idp:roles:assign` | Giving roles to people and taking them away | -| `idp:people:bind` | Choosing which roles the [People register](#people-roles) manages | -| `idp:applications:read` | Seeing the OIDC applications at `/applications` | -| `idp:applications:write` | Registering and editing applications, and rotating secrets | -| `idp:mcp` | Connecting an AI agent through the [MCP server](#mcp-server) | +| Permission | Covers | +| ------------------------ | ---------------------------------------------------------- | +| `idp:people:read` | Searching the people registered here | +| `idp:users:read` | Browsing the user directory at `/users` | +| `idp:users:delete` | Permanently deleting someone's account | +| `idp:permissions:read` | Seeing permissions in the `/access` section | +| `idp:permissions:write` | Creating, changing, and deleting permissions | +| `idp:roles:read` | Seeing roles, what they grant, and who holds them | +| `idp:roles:write` | Creating, changing, and deleting roles | +| `idp:roles:assign` | Giving roles to people and taking them away | +| `idp:applications:read` | Seeing the OIDC applications at `/applications` | +| `idp:applications:write` | Registering and editing applications, and rotating secrets | They use the permission hierarchy themselves: each `write` grants its `read`, `idp:roles:assign` grants `idp:roles:read` and `idp:people:read` so an assigner can see the role and find who to give it to, `idp:roles:write` also grants `idp:people:read`, `idp:users:read` grants `idp:people:read` because browsing everyone includes finding them, and `idp:roles:read` grants `idp:permissions:read` because a role is meaningless without seeing the permissions it carries. A role with `idp:roles:write` therefore ends up with four permissions and still cannot touch applications. `idp:roles:write` granting -`idp:roles:read`, `idp:roles:assign` and `idp:people:bind` granting `idp:roles:read`, `idp:permissions:write` +`idp:roles:read`, `idp:roles:assign` granting `idp:roles:read`, `idp:permissions:write` granting `idp:permissions:read` and `idp:users:delete` granting `idp:users:read` are fixed in code: they apply even if the stored edge is missing, and cannot be removed, because changing either without seeing what already exists makes no sense. The other implications @@ -332,7 +328,7 @@ keys cannot change; renaming a custom role's key breaks policies that match the Managing applications needs the `idp:applications:write` permission, so it can be given to any role. Master Admin holds it only through explicit deployment configuration. To use a Microsoft 365 administrators group distinct from Soci, set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to that group's object ID: only its direct members, checked through the same Graph credentials, keep it. Graph answers are cached for at most 60 seconds from lookup start per user; a failed check denies access instead of caching. `IDP_ADMIN_USER_IDS` remains a break-glass allowlist of local user IDs that always pass. Being a socio never confers administration by itself. -Dynamic registration is open to AI agents only, for the [MCP server](#mcp-server). Client-credentials grants are available when both OAuth resource identifiers are configured. Administrators manage every client at `/applications`. New applications go through a step-by-step wizard (Basics, Usage, Authentication, Access, then a review) with a summary that fills in as each step is completed; existing ones are edited on a settings page with a save bar. The app either signs people in, acts as itself (client credentials), or both, and that choice decides the credentials it may use: a client secret (shown once), a private key (a public Ed25519 or P-256 JWKS for `private_key_jwt`), or, for apps that only sign people in, none (public, PKCE only). Redirect URIs, the scopes people are asked for, and the PoliNetwork API scopes granted for signed-in people and for the app itself are chosen separately. Resource links follow the granted API scopes automatically. Everything except the client ID can be changed after creation, including the credential type: switching to a secret issues a new one, shown once, and switching away from a secret or key revokes it. Administrators can also rotate secrets, pause sign-ins by disabling an app, skip the consent screen for first-party apps, and delete apps. All administrators share one client pool (the plugin's `clientReference` is a fixed value), so clients are not tied to whoever created them. Redirect URIs follow the provider's rules, chosen per application as "Public https URLs only" (`application_type: web`) or "Also localhost and app schemes" (`native`: `http://localhost`, `http://127.0.0.1`, `http://[::1]`, or a reverse-domain custom scheme). Server functions in `src/auth/oidc.functions.ts` back the pages, including secret rotation and deletion. API access (backend and `idp:access:read` scopes, the client-credentials grant, and resource links) requires Master Admin, and so does any change to a client that has it. Creating a client and every change to an existing one check that rule and write in one transaction that holds the RBAC write lock, so a writer's rotation or deletion either finishes before a concurrent promotion or is refused after it. New clients are inserted directly, in the row format the provider uses, because its admin create endpoint needs a browser session and writes outside the lock. The provider's own client mutation endpoints (`/oauth2/create-client`, `/oauth2/update-client`, `/oauth2/delete-client`, `/oauth2/client/rotate-secret`) are disabled over HTTP and refused to server-side callers, because they check and write in separate statements outside that lock; resources are linked only by saving an application. +Dynamic registration is disabled. Client-credentials grants are available when both OAuth resource identifiers are configured. Administrators manage every client at `/applications`. New applications go through a step-by-step wizard (Basics, Usage, Authentication, Access, then a review) with a summary that fills in as each step is completed; existing ones are edited on a settings page with a save bar. The app either signs people in, acts as itself (client credentials), or both, and that choice decides the credentials it may use: a client secret (shown once), a private key (a public Ed25519 or P-256 JWKS for `private_key_jwt`), or, for apps that only sign people in, none (public, PKCE only). Redirect URIs, the scopes people are asked for, and the PoliNetwork API scopes granted for signed-in people and for the app itself are chosen separately. Resource links follow the granted API scopes automatically. Everything except the client ID can be changed after creation, including the credential type: switching to a secret issues a new one, shown once, and switching away from a secret or key revokes it. Administrators can also rotate secrets, pause sign-ins by disabling an app, skip the consent screen for first-party apps, and delete apps. All administrators share one client pool (the plugin's `clientReference` is a fixed value), so clients are not tied to whoever created them. Redirect URIs follow the provider's rules, chosen per application as "Public https URLs only" (`application_type: web`) or "Also localhost and app schemes" (`native`: `http://localhost`, `http://127.0.0.1`, `http://[::1]`, or a reverse-domain custom scheme). Server functions in `src/auth/oidc.functions.ts` back the pages, including secret rotation and deletion. API access (backend and `idp:access:read` scopes, the client-credentials grant, and resource links) requires Master Admin, and so does any change to a client that has it. Every change to an existing client checks that rule and writes in one transaction that holds the RBAC write lock, so a writer's rotation or deletion either finishes before a concurrent promotion or is refused after it. The provider's own client mutation endpoints (`/oauth2/create-client`, `/oauth2/update-client`, `/oauth2/delete-client`, `/oauth2/client/rotate-secret`) are disabled over HTTP and refused to server-side callers, because they check and write in separate statements outside that lock; resources are linked only by saving an application. ### Phase 1 backend access snapshot @@ -346,112 +342,22 @@ Master Admin registers the four Phase 1 clients on `/applications/new`, each wit During a sign-in the login page names the requesting application. The consent page at `/consent` shows the app, who is signed in (with a switch-account option), each requested scope in plain language, any requested profile claims, and where the browser will be sent; it supports allow and deny and explains expired or disabled requests. -New services follow [docs/service-integration-guide.md](docs/service-integration-guide.md). Future clients that sign people in should use authorization code with PKCE; service clients use client credentials, as the guide describes. All of them validate token signatures, issuer, audience, and expiration, and request `polinetwork:identity` only when needed. APIs must check their own permissions. An ID token or a linked Telegram ID alone is not permission to moderate a group. - -### People roles - -The admin dashboard's People register gives people predetermined roles: those bound, on the role's page (People register card), to what holding them means in People. A role's holders are admins of the Telegram groups (`admin`), Capo Admins (`capo`), or members or the Lead of a team (`team::member`, `team::lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Binding a role needs `idp:people:bind`, and, as when editing a role, only Master Admin can bind or unbind a role granting permissions they don't hold. Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. Bind only one role to each meaning: `trusted-admin` inherits `admin`, so binding it as `admin` too makes giving `admin` ambiguous (409). - -`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, each with every permission it grants (`permissions`, through parent roles too) and its own binding followed by those it inherits (`bindings`), and everyone holding them by hand or through a parent role, with their name, directory email, Telegram ID and username, which of those roles they were given directly, and since when they hold each one (`since`, `YYYY-MM-DD`: the earliest of their current assignments conferring it). Unlike the access snapshot it includes roles that grant no permission. People without a login account are the backend's own: they never appear here. - -`POST /api/internal/people-roles` (backend client, `idp:people:roles`) applies one People command `{ id, actor, sub, reason, changes }` on behalf of the dashboard user the backend authenticated. `sub` is always a login account. Each change names a desired state: `{ kind: "admin" | "capo", active }` or `{ kind: "team", team, role: "member" | "lead" | null }`. This IdP plans the concrete assignments against its own role graph and applies all of them or none: a concept with no bound role stays People's own and is skipped; it refuses with 409 when the mapping is ambiguous (not exactly one bound role to give), or when the requested state would still be held through another role (ending `admin` for a Capo Admin, say), rather than reporting a change that did not happen. - -The backend only names the actor; this IdP resolves the actor's own access and refuses every assignment, before any write, unless: - -- the actor isn't giving themselves a role (giving one up is allowed). When an invitee accepts an invite, the backend sends whoever made each assignment as the actor; -- the rule for the role's binding, and for every bound role it inherits, allows it (`PEOPLE_RULES` in `src/auth/people-binding.ts`): - - | Binding | Who may give or take it back | - | ------------------- | ------------------------------------------------------------------------ | - | `admin` | `admin:access` + `people:write` | - | `capo` | `admin:access` + `people:appoint` (and, through `admin`, `people:write`) | - | `team::member` | `admin:access` + `people:write`, or the Lead of that team | - | `team::lead` | `admin:access` + `people:appoint` | - -- unless Master Admin, the actor holds every permission the role grants. - -The backend applies the same rules to assignments it keeps for records that aren't linked yet, which this IdP only sees when the record is linked: that is what each bound role's `permissions` and `bindings` are for. - -The response is the person's bound roles after the command (`held`, `assigned`) and the bound role list. Each assignment is audited as `role.assign`/`role.unassign` with `via: "people"`, the reason and the command ID, and the command itself as `people.roles` under `people-command:`: repeating an ID with the same command, for example after a lost response, applies nothing again and returns the current roles, and repeating it with a different command is refused with 409. - -#### Deploying People roles - -Deploy this before the backend and dashboard that use it, then (PoliNetworkOrg/auth#79): - -1. Migrate (`0023_role_people_binding`). It adds the binding column and `idp:people:bind`, which only Master Admin holds until you grant it to a role. -2. Grant `idp:people:bind` to whoever should configure People, if anyone besides Master Admin. -3. Bind the roles. Team keys must match the backend's team list: - - | Role | Binding | - | ------------------------------- | ----------------------------------------- | - | `admin` | `admin` | - | `capo-admin` | `capo` | - | `hr-member` / `hr-lead` | `team:hr:member` / `team:hr:lead` | - | `design-member` / `design-lead` | `team:design:member` / `team:design:lead` | - | `intl-member` / `intl-lead` | `team:intl:member` / `team:intl:lead` | - | `events-member` / `events-lead` | `team:events:member` / `team:events:lead` | - - Leave `trusted-admin` unbound: it inherits `admin` and stays assigned by hand. `it-core` and `it-lead` carry `idp:*` permissions, so they can't be bound; the IT team needs plain team roles of its own (see #79). - -4. Grant the internal scope `idp:people:roles` to the backend client on its application page. Reads work without it. -5. Send one role command from the backend for a test account, and check that the audit log shows `via: people`. - -## MCP server - -`/api/mcp` is an MCP server, so an AI agent such as Claude Code or Codex can read and change this identity provider for you: debug an application that cannot sign people in, register or edit applications, and set up roles, permissions and who holds them. It only works for people holding `idp:mcp`, and the agent can only do what that person could do on the website. - -### Connect an agent - -Your account page shows the server URL and these commands when you hold `idp:mcp`. For production: - -| Agent | Command | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | -| Claude Code | `claude mcp add --scope user --transport http polinetwork-auth https://auth.polinetwork.org/api/mcp`, then `/mcp` to sign in | -| Codex | `codex mcp add polinetwork-auth --url https://auth.polinetwork.org/api/mcp` (signs in right away) | -| Gemini CLI | `gemini mcp add --scope user --transport http polinetwork-auth https://auth.polinetwork.org/api/mcp` | -| VS Code | `code --add-mcp '{"name":"polinetwork-auth","type":"http","url":"https://auth.polinetwork.org/api/mcp"}'` | - -Claude Desktop, claude.ai and ChatGPT add it as a custom connector with the same URL. The agent opens a browser, you sign in and approve it once, and it stays connected: it refreshes its access every few minutes, survives signing out of this website, and stops after 30 days without use. Cursor does not work yet: it registers a `cursor://` callback that is not a valid app scheme, so sign-in is refused. Agents that run inside a web page, such as the MCP Inspector in direct mode, do not work either: the token endpoint does not answer cross-origin requests. - -### What an agent can do - -Every tool calls the same code as the matching page, which checks the permission again, keeps the bounded delegation rules, takes the RBAC write lock, and writes the audit log under your name. Tools you have no permission for are not offered. - -| Permission | Tools | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `idp:mcp` | `whoami`, `get_server_info` (endpoints, token formats, rules), `inspect_token` (decode and verify a JWT) | -| `idp:applications:read` | `list_applications`, `get_application`, `diagnose_application` (finds bad settings and, given the authorization URL an app sends, says what `/oauth2/authorize` would answer and why) | -| `idp:applications:write` | `create_application`, `update_application`, `rotate_application_secret`, `delete_application` | -| `idp:people:read` | `search_people` | -| `idp:users:read` | `list_users`, `get_user`; with `idp:roles:read` also `explain_access` (why someone holds a permission) | -| `idp:roles:read` | `list_roles`, `get_role` | -| `idp:permissions:read` | `list_permissions` | -| `idp:roles:write` | `create_role`, `update_role`, `delete_role` | -| `idp:roles:assign` | `assign_role`, `unassign_role` | -| `idp:permissions:write` | `create_permission`, `update_permission`, `delete_permission` | -| `idp:people:bind` | `set_role_people_binding` | -| Master Admin | `list_audit_events` | - -Deleting people is left to the website. Deletes ask the agent to repeat the name or key. New client secrets go back to the agent once, like on the page, so it can hand them over. - -### How sign-in works - -The server follows the MCP authorization spec. `/.well-known/oauth-protected-resource/api/mcp` names this identity provider as its authorization server, and the authorization server metadata is also served at `/.well-known/oauth-authorization-server/api/auth` and `/.well-known/openid-configuration/api/auth`, where agents look first. Agents register themselves through dynamic client registration (`/api/auth/oauth2/register`), which is open but only to agents: - -- A registration must redirect to `http://localhost`, `http://127.0.0.1` or `http://[::1]` (any port), or exactly to a hosted agent's callback (`claude.ai`, `claude.com`, `chatgpt.com`, `vscode.dev`). Someone who tricks you into approving their client cannot collect the code on their own machine. -- It may use only the authorization code grant with refresh tokens, as a public client or with a secret, never with keys. A request with a browser session is refused: applications for people are registered from `/applications`. -- Its scopes are limited to `idp:mcp` and `offline_access`, and agents that ask for more get only these. Without `openid` it gets no ID token and cannot call UserInfo. It is linked to the MCP server alone, and a request that names no `resource` gets a token for the MCP server, so its tokens are useless anywhere else. -- Its logo, links and logout addresses are dropped, because PoliNetwork would seem to vouch for them. The consent page shows its name and where it redirects. +Future consumers should use authorization code with PKCE, validate token signatures, issuer, audience, and expiration, and request `polinetwork:identity` only when needed. APIs must check their own permissions. An ID token or a linked Telegram ID alone is not permission to moderate a group. -Tokens are EdDSA JWTs addressed to `/api/mcp`, valid five minutes. Every request checks the token, that the agent's client still exists with your consent, and that you still hold `idp:mcp`, so taking the permission away or disconnecting the agent from your account page ends access on the next request. People without `idp:mcp` are stopped on the consent page. Registrations nobody approves within a day are deleted, and so are agents nobody has used for 60 days; each use is recorded on the agent's consent, at most hourly. Registration is rate-limited to five a minute per address. +## Migration work before cutover -## Backend login removal +Inspection found backend auth in `../backend/src/auth/index.ts`, with custom email OTP, passkeys, shared subdomain cookies, and custom Telegram linking. `../admin` reads `user.telegramId` and calls backend Telegram permission routes. `../group-bot` also uses those backend assignments. -The backend's own login (better-auth with email OTP, passkeys and Telegram link codes) was removed in RFC v3 Phase 6: every PoliNetwork service signs people in through this IdP. Browsers may still hold the backend's old `better-auth` cookies on `.polinetwork.org` until they expire; this service's `pn-identity` cookie prefix (`src/auth/cookies.ts`) keeps them from shadowing its session. +The backend scopes its Better Auth cookies to `.polinetwork.org` with the default `better-auth` prefix, so browsers send them to this service too. This service uses its own `pn-identity` cookie prefix (`src/auth/cookies.ts`) so those cookies cannot shadow its session. Keep the prefixes different while both run. -To connect a new service, follow [docs/service-integration-guide.md](docs/service-integration-guide.md). +Keep these integrations running while testing this service. A later migration needs: -Still open: confirm the PN membership rule and how often students must reverify their Polimi email. +1. Back up and inventory backend users, accounts, passkeys, Telegram links, roles, and foreign keys. Preserve user IDs or define a reviewed mapping. This prototype's schema is not a replacement migration for the backend's prefixed tables. +2. Implement a bridge that proves ownership of the existing backend session before connecting an existing user here. Existing backend passkeys are not imported; users register new passkeys in this service. The prototype does not preserve email OTP login. Never import Telegram usernames as ownership proof or manufacture OIDC accounts from old links. +3. Require official Telegram re-verification and reconcile the verified numeric ID with existing moderation assignments. Review conflicts instead of merging automatically. +4. Confirm the PN membership rule and choose how often students must reverify their Polimi email. +5. Test a downstream application in staging, including authorization code, consent, refresh, logout, account conflicts, and revoked permissions. The current shared-cookie clients cannot be pointed at this issuer without integration changes. +6. Schedule a separate cutover and rollback plan. Retain the backend login until those checks pass. ## Validation diff --git a/drizzle/0024_presidente_segretario_roles.sql b/drizzle/0024_presidente_segretario_roles.sql index aa6bb7d..ef00219 100644 --- a/drizzle/0024_presidente_segretario_roles.sql +++ b/drizzle/0024_presidente_segretario_roles.sql @@ -1,17 +1,17 @@ -- A hand-made role already using one of these keys would silently stand in for the board --- office, keeping its hand assignments. Stop instead, so an administrator renames it first. +-- office. Stop instead, so an administrator renames it first. DO $$ BEGIN IF EXISTS (SELECT 1 FROM "role" WHERE "key" IN ('presidente', 'segretario') AND NOT "managed") THEN RAISE EXCEPTION 'Rename the existing presidente or segretario role before migrating: those keys now belong to built-in roles.'; END IF; END $$;--> statement-breakpoint --- The President and Secretary of the board. Like Direttivo, membership follows direct --- membership of an Entra group (PN_ENTRA_PRESIDENTE_GROUP_ID, PN_ENTRA_SEGRETARIO_GROUP_ID), --- so the application never writes user_role rows for them. +-- The President and Secretary of the board. They are built in, but unlike the other +-- built-in roles they are given by hand, and only by a Master Admin, so they have no +-- source_state. INSERT INTO "role" ("id", "key", "name", "description", "managed", "source_state") VALUES - ('static-role-presidente', 'presidente', 'Presidente', 'President of the PoliNetwork APS board.', true, 'presidente'), - ('static-role-segretario', 'segretario', 'Segretario', 'Secretary of the PoliNetwork APS board.', true, 'segretario') + ('static-role-presidente', 'presidente', 'Presidente', 'President of the PoliNetwork APS board.', true, NULL), + ('static-role-segretario', 'segretario', 'Segretario', 'Secretary of the PoliNetwork APS board.', true, NULL) ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint -- Both offices sit on the board, so they always inherit everything Direttivo grants. INSERT INTO "role_parent" ("role_id", "parent_role_id") @@ -19,4 +19,22 @@ SELECT office.id, board.id FROM "role" office JOIN "role" board ON board.key = 'direttivo' WHERE office.key IN ('presidente', 'segretario') -ON CONFLICT DO NOTHING; +ON CONFLICT DO NOTHING;--> statement-breakpoint +-- Managed roles still refuse hand-made assignments, except the two board offices. +CREATE OR REPLACE FUNCTION guard_managed_role_links() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_TABLE_NAME = 'user_role' THEN + IF EXISTS ( + SELECT 1 FROM role + WHERE id = NEW.role_id AND managed AND key NOT IN ('presidente', 'segretario') + ) THEN + RAISE EXCEPTION 'Managed roles cannot be assigned'; + END IF; + ELSE + IF EXISTS (SELECT 1 FROM role WHERE id = NEW.parent_role_id AND key = 'master-admin') THEN + RAISE EXCEPTION 'Master Admin cannot be inherited'; + END IF; + END IF; + RETURN NEW; +END; +$$; diff --git a/scripts/security-config.mjs b/scripts/security-config.mjs index 58faaf9..83e8994 100644 --- a/scripts/security-config.mjs +++ b/scripts/security-config.mjs @@ -37,8 +37,6 @@ const schema = z OAUTH_BACKEND_EVENTS_URL: optional(z.url()), PN_ENTRA_MEMBER_GROUP_ID: optional(z.uuid()), PN_ENTRA_DIRETTIVO_GROUP_ID: optional(z.uuid()), - PN_ENTRA_PRESIDENTE_GROUP_ID: optional(z.uuid()), - PN_ENTRA_SEGRETARIO_GROUP_ID: optional(z.uuid()), PN_ENTRA_MEMBER_REFRESH_HOURS: optional(z.coerce.number().int().positive()), STUDENT_VERIFICATION_TTL_DAYS: optional(z.coerce.number().int().positive()), GOOGLE_CLIENT_ID: optional(z.string().trim().min(1)), @@ -126,9 +124,7 @@ const schema = z if ( (credentials.some(Boolean) || config.PN_ENTRA_OIDC_ADMIN_GROUP_ID || - config.PN_ENTRA_DIRETTIVO_GROUP_ID || - config.PN_ENTRA_PRESIDENTE_GROUP_ID || - config.PN_ENTRA_SEGRETARIO_GROUP_ID) && + config.PN_ENTRA_DIRETTIVO_GROUP_ID) && !credentials.every(Boolean) ) context.addIssue({ diff --git a/src/auth/access-snapshot.ts b/src/auth/access-snapshot.ts index 4207ff3..ae5e85d 100644 --- a/src/auth/access-snapshot.ts +++ b/src/auth/access-snapshot.ts @@ -14,7 +14,7 @@ import { authorizationMutationLock } from "../db/security-lock"; import { env } from "../env"; import { listEntraGroupMembers } from "./membership"; import { isManagedPermissionKey, resolveAccess, type RbacCatalog } from "./rbac"; -import { readCatalog } from "./rbac-store"; +import { handAssignableRole, readCatalog } from "./rbac-store"; const ENTRA_GRACE_MS = 3_600_000; // The backend polls every 30 s; reusing a recent listing bounds Graph reads per group. @@ -69,20 +69,6 @@ function configuredGroups(): Group[] { groupId: env.PN_ENTRA_DIRETTIVO_GROUP_ID, } : null, - env.PN_ENTRA_PRESIDENTE_GROUP_ID - ? { - source: "entra:presidente", - roleKey: "presidente", - groupId: env.PN_ENTRA_PRESIDENTE_GROUP_ID, - } - : null, - env.PN_ENTRA_SEGRETARIO_GROUP_ID - ? { - source: "entra:segretario", - roleKey: "segretario", - groupId: env.PN_ENTRA_SEGRETARIO_GROUP_ID, - } - : null, env.PN_ENTRA_OIDC_ADMIN_GROUP_ID ? { source: "entra:admins", @@ -285,7 +271,7 @@ export async function buildBackendSnapshot(): Promise { .select({ userId: userRole.userId, roleKey: role.key }) .from(userRole) .innerJoin(role, eq(userRole.roleId, role.id)) - .where(eq(role.managed, false)); + .where(handAssignableRole()); const proofs = await transaction .select({ userId: account.userId, diff --git a/src/auth/account-overview.ts b/src/auth/account-overview.ts index cdef9fd..39021df 100644 --- a/src/auth/account-overview.ts +++ b/src/auth/account-overview.ts @@ -5,15 +5,15 @@ import { identityEvidence } from "../db/evidence"; import { db } from "../db/index"; import { appEmail, claimEmail } from "./contact-email"; import type { IdentityClaims } from "./policy"; -import { STATIC_ROLES } from "./rbac"; +import { STATIC_ROLES, takesHandAssignments } from "./rbac"; import { readCatalog } from "./rbac-store"; import { isPlaceholderEmail } from "./users"; /** The sign-in accounts whose address the account page shows on its card. */ const EMAIL_PROVIDERS = ["google", "pn-entra"] as const; -/** Socio, Direttivo and the board offices are groups in PoliNetwork's Entra tenant, so they read as groups. */ -const GROUP_STATES = ["socio", "direttivo", "presidente", "segretario"]; +/** Socio and Direttivo are groups in PoliNetwork's Entra tenant, so they read as groups. */ +const GROUP_STATES = ["socio", "direttivo"]; /** * What the account page shows besides the linked accounts and passkeys: the address on each @@ -94,7 +94,7 @@ export async function readAccountOverview( })); const assigned = identity.roles.flatMap((key) => { const role = roles.get(key); - return role && !role.managed + return role && takesHandAssignments(role) ? [{ key, name: role.name, description: role.description, automatic: false }] : []; }); diff --git a/src/auth/identity-subject.ts b/src/auth/identity-subject.ts index 6277fa0..8900b4a 100644 --- a/src/auth/identity-subject.ts +++ b/src/auth/identity-subject.ts @@ -58,8 +58,6 @@ export async function readIdentitySubject( return [ ["socio", env.PN_ENTRA_MEMBER_GROUP_ID], ["direttivo", env.PN_ENTRA_DIRETTIVO_GROUP_ID], - ["presidente", env.PN_ENTRA_PRESIDENTE_GROUP_ID], - ["segretario", env.PN_ENTRA_SEGRETARIO_GROUP_ID], ].map(async ([state, groupId]) => { if (groupId && (await check(groupId, objectId))) verifiedStates.add(state!); }); diff --git a/src/auth/mcp-server.ts b/src/auth/mcp-server.ts index 7a40556..4346e76 100644 --- a/src/auth/mcp-server.ts +++ b/src/auth/mcp-server.ts @@ -41,6 +41,7 @@ import { permissionDraftFrom, roleDraftFrom, staticRolesForStates, + takesHandAssignments, type ManagedPermissionKey, type RbacCatalog, } from "./rbac"; @@ -679,9 +680,9 @@ export function createMcpServer(principal: McpPrincipal) { inheritedBy: catalog.roles .filter((entry) => entry.parents.includes(found.key)) .map((entry) => entry.key), - members: found.managed - ? "Built-in roles are held through identity evidence, not by hand." - : await listRoleMembers(actor, found.id, after), + members: takesHandAssignments(found) + ? await listRoleMembers(actor, found.id, after) + : "Built-in roles are held through identity evidence, not by hand.", }; }), ); diff --git a/src/auth/membership.ts b/src/auth/membership.ts index 2fa65b2..988758f 100644 --- a/src/auth/membership.ts +++ b/src/auth/membership.ts @@ -119,8 +119,6 @@ export async function checkPnGroupStates(objectId: string): Promise Boolean(group.groupId)); const memberships = await Promise.all( groups.map((group) => checkEntraGroupMember(group.groupId, objectId)), diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index 4c52535..c13cbb3 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -17,7 +17,13 @@ import { planPeopleCommand, } from "./people-role-command"; import { expandRoleKeys, resolveAccess } from "./rbac"; -import { RbacError, assignedRoleKeys, auditSnapshot, readCatalog } from "./rbac-store"; +import { + RbacError, + assignedRoleKeys, + auditSnapshot, + handAssignableRole, + readCatalog, +} from "./rbac-store"; export type PeopleDirectory = { /** Every role People manages, with what holding it means. */ @@ -59,7 +65,7 @@ export async function readPeopleDirectory(): Promise { .select({ userId: userRole.userId, roleKey: role.key, assignedAt: userRole.assignedAt }) .from(userRole) .innerJoin(role, eq(userRole.roleId, role.id)) - .where(eq(role.managed, false)); + .where(handAssignableRole()); const assigned = new Map(); const since = new Map>(); for (const row of assignments) { diff --git a/src/auth/people-search.ts b/src/auth/people-search.ts index ebcb5db..1a80ea6 100644 --- a/src/auth/people-search.ts +++ b/src/auth/people-search.ts @@ -4,7 +4,7 @@ import { account, identityEvidence, passkey, user, userRole } from "../db/schema import { env } from "../env"; import { matchesSearch } from "../lib/search"; import { groupMembers } from "./group-listing"; -import type { RbacCatalog } from "./rbac"; +import { type RbacCatalog, takesHandAssignments } from "./rbac"; import { type GroupTrait, type MembershipSource, type UserTrait, USER_TRAITS } from "./users"; type Transaction = Parameters[0]>[0]; @@ -141,7 +141,8 @@ export function searchCondition( ? catalog.roles .filter( (role) => - !role.managed && matchesSearch(query, [role.key, role.name, role.description]), + takesHandAssignments(role) && + matchesSearch(query, [role.key, role.name, role.description]), ) .map((role) => exists( diff --git a/src/auth/policy.ts b/src/auth/policy.ts index afef8ca..760de1b 100644 --- a/src/auth/policy.ts +++ b/src/auth/policy.ts @@ -9,8 +9,6 @@ export type IdentityEvidence = { const STATE_PROVIDERS: Record = { socio: "pn-entra", direttivo: "pn-entra", - presidente: "pn-entra", - segretario: "pn-entra", student: "polimi-email", }; diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs index 242a82c..ed7f70d 100644 --- a/src/auth/rbac-security.integration.test.mjs +++ b/src/auth/rbac-security.integration.test.mjs @@ -299,6 +299,29 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre ).toBe(403); }); + it("leaves the board offices to Master Admin and gives their holders Direttivo", async () => { + const actor = await delegate(["idp:roles:assign"]); + const roles = (await loadCatalog(root)).roles; + const direttivo = roles.find((entry) => entry.key === "direttivo"); + for (const key of ["presidente", "segretario"]) { + const office = roles.find((entry) => entry.key === key); + const change = (action) => + call(changeRoleMemberFn, actor.id, { action, roleId: office.id, userId: ordinary }); + expect((await change("assign")).status).toBe(403); + await assignRole(root, office.id, ordinary); + try { + expect((await getIdentity(ordinary)).roles).toEqual( + expect.arrayContaining([key, "direttivo"]), + ); + expect((await change("unassign")).status).toBe(403); + } finally { + await unassignRole(root, office.id, ordinary); + } + expect((await getIdentity(ordinary)).roles).not.toContain(key); + } + await expect(assignRole(root, direttivo.id, ordinary)).rejects.toThrow(/Managed roles/); + }); + it("preserves bounded delegation while refusing direct and indirect managed-role edits", async () => { const own = await savePermission(root, draftPermission(unique("own"))); const actor = await delegate(["idp:roles:write", "idp:roles:assign", own.key]); diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts index 97ccbcf..79280ce 100644 --- a/src/auth/rbac-store.ts +++ b/src/auth/rbac-store.ts @@ -7,7 +7,7 @@ import { logAuthorizationDenial } from "./denial-log"; import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; import type { IdentityClaims } from "./policy"; import { randomUUID } from "node:crypto"; -import { and, count, eq, gt, sql } from "drizzle-orm"; +import { and, count, eq, gt, inArray, or, sql } from "drizzle-orm"; import { db } from "../db/index"; import { authorizationMutationLock } from "../db/security-lock"; import { @@ -32,6 +32,8 @@ import { type UserSearchResult, MASTER_ADMIN_ROLE_KEY, hasDraftErrors, + MASTER_ASSIGNED_ROLE_KEYS, + takesHandAssignments, catalogForIdpPermissions, normalizePermissionDraft, normalizeRoleDraft, @@ -501,10 +503,33 @@ export async function listRoleMembers( }); } +/** Roles whose `user_role` rows count: the same rule as `takesHandAssignments`, in SQL. */ +export function handAssignableRole() { + return or(eq(role.managed, false), inArray(role.key, MASTER_ASSIGNED_ROLE_KEYS)); +} + +/** Refuses a hand-made change to a role that takes none, or that only a Master Admin makes. */ +function requireHandAssignment( + actorId: string, + target: RoleSummary, + access: ResolvedAccess, + verb: "assigned" | "revoked", +) { + if (!takesHandAssignments(target)) + throw new RbacError(400, `Managed roles cannot be ${verb} by hand.`); + if (target.managed && !access.roles.includes(MASTER_ADMIN_ROLE_KEY)) { + logAuthorizationDenial(actorId, "rbac-store", ["master-admin"]); + throw new RbacError( + 403, + `Only a Master Admin can ${verb === "assigned" ? "assign" : "revoke"} ${target.name}.`, + ); + } +} + export async function assignRole(actorId: string, roleId: string, userId: string) { - await withRbacWriteLock(actorId, "role.assign", roleId, async (transaction, catalog) => { + await withRbacWriteLock(actorId, "role.assign", roleId, async (transaction, catalog, access) => { const target = requireRole(catalog, roleId); - if (target.managed) throw new RbacError(400, "Managed roles cannot be assigned by hand."); + requireHandAssignment(actorId, target, access, "assigned"); const [found] = await transaction.select({ id: user.id }).from(user).where(eq(user.id, userId)); if (!found) throw new RbacError(404, "That person was not found."); await transaction @@ -515,13 +540,18 @@ export async function assignRole(actorId: string, roleId: string, userId: string } export async function unassignRole(actorId: string, roleId: string, userId: string) { - await withRbacWriteLock(actorId, "role.unassign", roleId, async (transaction, catalog) => { - const target = requireRole(catalog, roleId); - if (target.managed) throw new RbacError(400, "Managed roles cannot be revoked by hand."); - await transaction - .delete(userRole) - .where(and(eq(userRole.roleId, roleId), eq(userRole.userId, userId))); - }); + await withRbacWriteLock( + actorId, + "role.unassign", + roleId, + async (transaction, catalog, access) => { + const target = requireRole(catalog, roleId); + requireHandAssignment(actorId, target, access, "revoked"); + await transaction + .delete(userRole) + .where(and(eq(userRole.roleId, roleId), eq(userRole.userId, userId))); + }, + ); } /** @@ -581,7 +611,7 @@ export async function searchUsers( ); } -/** The role keys a person has been given by hand, ignoring anything managed. */ +/** The role keys a person has been given by hand, ignoring roles that take no assignments. */ export async function assignedRoleKeys( userId: string, catalog: RbacCatalog, @@ -592,7 +622,7 @@ export async function assignedRoleKeys( .from(userRole) .where(eq(userRole.userId, userId)); const assignable = new Map( - catalog.roles.filter((entry) => !entry.managed).map((entry) => [entry.id, entry.key]), + catalog.roles.filter(takesHandAssignments).map((entry) => [entry.id, entry.key]), ); return rows.flatMap((row) => { const key = assignable.get(row.roleId); diff --git a/src/auth/rbac.test.ts b/src/auth/rbac.test.ts index f0e32d2..da912c8 100644 --- a/src/auth/rbac.test.ts +++ b/src/auth/rbac.test.ts @@ -15,6 +15,7 @@ import { resolveAccess, roleParentWouldCycle, staticRolesForStates, + takesHandAssignments, validatePermissionDraft, validateRoleDraft, withIntrinsicImplications, @@ -130,9 +131,16 @@ describe("roles the identity provider defines itself", () => { expect(isStaticRoleKey("chair")).toBe(false); }); - it("confers the board offices from their own states", () => { - expect(staticRolesForStates(["presidente", "socio"])).toEqual(["socio", "presidente"]); - expect(staticRolesForStates(["segretario"])).toEqual(["segretario"]); + it("never confers the board offices from a state", () => { + expect(staticRolesForStates(["presidente", "segretario", "direttivo"])).toEqual(["direttivo"]); + }); + + it("takes hand assignments only for created roles and the board offices", () => { + for (const key of ["presidente", "segretario"]) + expect(takesHandAssignments({ key, managed: true })).toBe(true); + for (const key of ["master-admin", "socio", "direttivo", "student"]) + expect(takesHandAssignments({ key, managed: true })).toBe(false); + expect(takesHandAssignments({ key: "chair", managed: false })).toBe(true); }); it("refuses a new role that would shadow one of them", () => { diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts index f7249d3..a65238e 100644 --- a/src/auth/rbac.ts +++ b/src/auth/rbac.ts @@ -1,10 +1,11 @@ // Shared by the server and the browser: keep this file free of server-only imports. /** - * Roles the identity provider always defines itself. They exist without being created, - * cannot be deleted or assigned by hand, and are conferred by the rest of the identity - * provider rather than by an administrator: `state` names the identity state that proves - * the role, and is null for a role conferred some other way. + * Roles the identity provider always defines itself. They exist without being created and + * cannot be deleted. Most are conferred by the rest of the identity provider rather than by + * an administrator: `state` names the identity state that proves the role, and is null for + * a role conferred some other way. The board offices are the exception: `masterAssigned` + * roles are given and taken back by hand, and only by a Master Admin. * * Administrators can still rename them, describe them, give them permissions, and place * them in the role hierarchy — only their membership is out of their hands. `parents` are @@ -16,6 +17,7 @@ export const STATIC_ROLES = [ state: null, /** Holds every permission that exists, including ones created later. */ grantsAllPermissions: true, + masterAssigned: false, parents: [], name: "Master Admin", description: "Complete control of this identity provider.", @@ -26,6 +28,7 @@ export const STATIC_ROLES = [ key: "socio", state: "socio", grantsAllPermissions: false, + masterAssigned: false, parents: [], name: "Socio", description: "Member of PoliNetwork APS.", @@ -36,6 +39,7 @@ export const STATIC_ROLES = [ key: "direttivo", state: "direttivo", grantsAllPermissions: false, + masterAssigned: false, parents: [], name: "Direttivo", description: "Member of the PoliNetwork APS board.", @@ -44,30 +48,31 @@ export const STATIC_ROLES = [ }, { key: "presidente", - state: "presidente", + state: null, grantsAllPermissions: false, + masterAssigned: true, /** The President is on the board, so holds everything Direttivo grants. */ parents: ["direttivo"], name: "Presidente", description: "President of the PoliNetwork APS board.", - evidence: - "Direct membership of the Presidente group in PoliNetwork Entra ID. Membership checks expire after one minute.", + evidence: "Given by hand, and only by a Master Admin.", }, { key: "segretario", - state: "segretario", + state: null, grantsAllPermissions: false, + masterAssigned: true, /** The Secretary is on the board, so holds everything Direttivo grants. */ parents: ["direttivo"], name: "Segretario", description: "Secretary of the PoliNetwork APS board.", - evidence: - "Direct membership of the Segretario group in PoliNetwork Entra ID. Membership checks expire after one minute.", + evidence: "Given by hand, and only by a Master Admin.", }, { key: "student", state: "student", grantsAllPermissions: false, + masterAssigned: false, parents: [], name: "Student", description: "Verified Politecnico di Milano student.", @@ -103,6 +108,19 @@ export function staticRolesForStates(states: readonly string[]): string[] { ); } +/** Built-in roles that a Master Admin gives and takes back by hand. */ +export const MASTER_ASSIGNED_ROLE_KEYS: string[] = STATIC_ROLES.filter( + (entry) => entry.masterAssigned, +).map((entry) => entry.key); + +/** + * Whether people hold this role through hand-made assignments: every role an administrator + * created, plus the built-in roles a Master Admin assigns. + */ +export function takesHandAssignments(role: { key: string; managed: boolean }) { + return !role.managed || MASTER_ASSIGNED_ROLE_KEYS.includes(role.key); +} + /** The roles a built-in role always inherits from. */ export function intrinsicParents(key: string): readonly string[] { return staticRole(key)?.parents ?? []; @@ -251,14 +269,17 @@ export type RoleSummary = { key: string; name: string; description: string | null; - /** Managed roles are inferred from identity evidence and never assigned by hand. */ + /** + * Managed roles are defined by the identity provider. Their holders come from identity + * evidence, except the ones only a Master Admin assigns; see `takesHandAssignments`. + */ managed: boolean; sourceState: string | null; /** Keys of the permissions granted directly, before inheritance. */ permissions: string[]; /** Keys of the roles this role inherits permissions from. */ parents: string[]; - /** People holding a hand-made assignment. Always 0 for managed roles. */ + /** People holding a hand-made assignment. Always 0 for roles that take none. */ memberCount: number; /** What holding it means in the People register; see `people-binding.ts`. */ peopleBinding: string | null; diff --git a/src/auth/user-directory.ts b/src/auth/user-directory.ts index 2f7fd72..cff8683 100644 --- a/src/auth/user-directory.ts +++ b/src/auth/user-directory.ts @@ -5,7 +5,7 @@ import { contactEmails } from "./contact-email"; import { logAuthorizationDenial } from "./denial-log"; import { groupListings, traitConditions, searchCondition } from "./people-search"; import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; -import { resolveAccess } from "./rbac"; +import { resolveAccess, takesHandAssignments } from "./rbac"; import { RbacError, assignedRoleKeys, withAuthorizedRbacRead } from "./rbac-store"; import { type UserDetail, @@ -49,7 +49,9 @@ export async function listUsers(actorId: string, search: UserSearch): Promise entry.key === search.role && !entry.managed); + const target = catalog.roles.find( + (entry) => entry.key === search.role && takesHandAssignments(entry), + ); filters.push( target ? exists( diff --git a/src/components/rbac/delegation.test.ts b/src/components/rbac/delegation.test.ts index f3f02bf..0bd04c8 100644 --- a/src/components/rbac/delegation.test.ts +++ b/src/components/rbac/delegation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import type { RbacCatalog } from "@/auth/rbac"; -import { canGrantPermission, canGrantRole } from "./delegation"; +import { canAssignRole, canGrantPermission, canGrantRole } from "./delegation"; const catalog: RbacCatalog = { permissions: ["read", "write"].map((key) => ({ @@ -63,3 +63,20 @@ describe("delegation choices", () => { expect(canGrantRole(access, catalog, "inherited-writer")).toBe(true); }); }); + +describe("assigning the board offices", () => { + const office = { ...catalog.roles[0]!, id: "presidente", key: "presidente", managed: true }; + const offices: RbacCatalog = { ...catalog, roles: [...catalog.roles, office] }; + const direttivo = { ...office, id: "direttivo", key: "direttivo" }; + + it("is left to Master Admin, even for someone holding everything the office grants", () => { + expect(canAssignRole({ isMasterAdmin: true, permissions: [] }, offices, office)).toBe(true); + expect( + canAssignRole({ isMasterAdmin: false, permissions: ["read", "write"] }, offices, office), + ).toBe(false); + }); + + it("never covers built-in roles proven by evidence", () => { + expect(canAssignRole({ isMasterAdmin: true, permissions: [] }, offices, direttivo)).toBe(false); + }); +}); diff --git a/src/components/rbac/delegation.ts b/src/components/rbac/delegation.ts index d716530..8221a22 100644 --- a/src/components/rbac/delegation.ts +++ b/src/components/rbac/delegation.ts @@ -1,4 +1,10 @@ -import { effectiveRolePermissions, expandPermissionKeys, type RbacCatalog } from "@/auth/rbac"; +import { + effectiveRolePermissions, + expandPermissionKeys, + type RbacCatalog, + type RoleSummary, + takesHandAssignments, +} from "@/auth/rbac"; type DelegationAccess = { isMasterAdmin: boolean; permissions: readonly string[] }; @@ -20,3 +26,11 @@ export function canGrantRole(access: DelegationAccess, catalog: RbacCatalog, key ) ); } + +/** Built-in roles that take assignments are given and taken back by Master Admin only. */ +export function canAssignRole(access: DelegationAccess, catalog: RbacCatalog, role: RoleSummary) { + return ( + takesHandAssignments(role) && + (access.isMasterAdmin || (!role.managed && canGrantRole(access, catalog, role.key))) + ); +} diff --git a/src/components/users/user-roles.tsx b/src/components/users/user-roles.tsx index 3134e36..a949afe 100644 --- a/src/components/users/user-roles.tsx +++ b/src/components/users/user-roles.tsx @@ -2,11 +2,11 @@ import { Link, useRouter } from "@tanstack/react-router"; import { useState } from "react"; import { LoaderCircle, ShieldCheck, Sparkles, UserMinus, UserPlus } from "lucide-react"; import { toast } from "sonner"; -import type { RbacCatalog } from "@/auth/rbac"; +import { type RbacCatalog, takesHandAssignments } from "@/auth/rbac"; import type { UserRoleAssignment } from "@/auth/users"; import { changeRoleMemberFn } from "@/auth/rbac.functions"; import { useAccess } from "@/components/access"; -import { canGrantRole } from "@/components/rbac/delegation"; +import { canAssignRole } from "@/components/rbac/delegation"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -48,9 +48,17 @@ export function UserRoles({ const [busy, setBusy] = useState(""); const roleByKey = (key: string) => catalog.roles.find((role) => role.key === key); - const mayGrant = (key: string) => canGrantRole(access, catalog, key); + const mayGrant = (key: string) => { + const role = roleByKey(key); + return !!role && canAssignRole(access, catalog, role); + }; const available = catalog.roles - .filter((role) => !role.managed && !assigned.some((entry) => entry.key === role.key)) + .filter( + (role) => + takesHandAssignments(role) && + (access.isMasterAdmin || !role.managed) && + !assigned.some((entry) => entry.key === role.key), + ) .sort((a, b) => a.name.localeCompare(b.name)); // Held through a built-in role or inheritance rather than given by hand. const other = held.filter((key) => !assigned.some((entry) => entry.key === key)); diff --git a/src/env.ts b/src/env.ts index babc8f5..e175671 100644 --- a/src/env.ts +++ b/src/env.ts @@ -27,10 +27,6 @@ export const env = createEnv({ // Optional Entra group whose direct members hold the built-in Direttivo role. // Unset: nobody is inferred as Direttivo. PN_ENTRA_DIRETTIVO_GROUP_ID: z.uuid().optional(), - // Optional Entra groups whose direct members hold the built-in Presidente and - // Segretario roles. Unset: nobody is inferred as holding that office. - PN_ENTRA_PRESIDENTE_GROUP_ID: z.uuid().optional(), - PN_ENTRA_SEGRETARIO_GROUP_ID: z.uuid().optional(), PN_ENTRA_MEMBER_REFRESH_HOURS: z.coerce.number().int().positive().default(24), // Optional stricter Entra group whose direct members hold the built-in Master Admin // role, and through it every permission. diff --git a/src/routes/access/roles/$roleId.tsx b/src/routes/access/roles/$roleId.tsx index b3c67d2..e830d12 100644 --- a/src/routes/access/roles/$roleId.tsx +++ b/src/routes/access/roles/$roleId.tsx @@ -9,6 +9,7 @@ import { effectiveRolePermissions, roleDraftFrom, staticRole, + takesHandAssignments, } from "@/auth/rbac"; import { deleteRoleFn, getCatalog, getRoleMembers, saveRoleFn } from "@/auth/rbac.functions"; import { requireAccess, useAccess } from "@/components/access"; @@ -18,7 +19,7 @@ import { KeyChip } from "@/components/rbac/fields"; import { PeopleBindingCard } from "@/components/rbac/people-binding-card"; import { RoleForm } from "@/components/rbac/role-form"; import { RoleMembers } from "@/components/rbac/role-members"; -import { canGrantRole } from "@/components/rbac/delegation"; +import { canAssignRole, canGrantRole } from "@/components/rbac/delegation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { errorFields, errorMessage } from "@/lib/action-error"; @@ -56,10 +57,7 @@ function RoleDetail() { canGrantRole(access, catalog, role.key); // Handing a role out is delegated separately from changing what it grants. const canAssign = - access.can("idp:roles:assign") && - !!role && - !role.managed && - canGrantRole(access, catalog, role.key); + access.can("idp:roles:assign") && !!role && canAssignRole(access, catalog, role); // Binding hands the role's membership to People, so it is bounded the same way. const canBind = access.can("idp:people:bind") && !!role && canGrantRole(access, catalog, role.key); @@ -108,8 +106,9 @@ function RoleDetail() { ); } - const inferred = role.managed ? staticRole(role.key) : undefined; - const grantsEverything = inferred?.grantsAllPermissions ?? false; + const builtIn = role.managed ? staticRole(role.key) : undefined; + const grantsEverything = builtIn?.grantsAllPermissions ?? false; + const inferred = takesHandAssignments(role) ? undefined : builtIn; const effective = effectiveRolePermissions(catalog, role.key); return ( @@ -180,7 +179,7 @@ function RoleDetail() { - {role.managed ? "Who holds this role" : "People"} + {inferred ? "Who holds this role" : "People"} {inferred ? inferred.evidence @@ -190,7 +189,7 @@ function RoleDetail() { - {role.managed ? ( + {inferred ? (

Membership is worked out for each person as their tokens are issued, so there is no list to edit here. diff --git a/src/routes/users/index.tsx b/src/routes/users/index.tsx index cda2202..b08af9b 100644 --- a/src/routes/users/index.tsx +++ b/src/routes/users/index.tsx @@ -19,7 +19,7 @@ import { X, } from "lucide-react"; import { cn } from "cn"; -import { emptyCatalog } from "@/auth/rbac"; +import { emptyCatalog, takesHandAssignments } from "@/auth/rbac"; import { getCatalog } from "@/auth/rbac.functions"; import { getUsers } from "@/auth/users.functions"; import { @@ -247,7 +247,7 @@ function UsersIndex() { return () => clearTimeout(timer); }, [query]); - const customRoles = catalog.roles.filter((role) => !role.managed); + const customRoles = catalog.roles.filter(takesHandAssignments); const roleName = (key: string) => catalog.roles.find((role) => role.key === key)?.name ?? key; const filtered = hasUserFilters(search); const first = (data.page - 1) * data.pageSize + 1; From 1a844478ce4a72d56d983920ae27ae09f72ced6b Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Mon, 2 Nov 2026 20:06:43 +0100 Subject: [PATCH 3/3] docs(roles): restore README content lost in the previous commit --- README.md | 146 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 119 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 6044223..697bb9a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PoliNetwork Auth prototype -A standalone TanStack Start and Better Auth identity provider. The backend remains the active authentication provider for all existing PoliNetwork applications. This repository does not change backend authentication, migrate production data, or grant Telegram moderation access. +A standalone TanStack Start and Better Auth identity provider. As of RFC v3 Phase 6 (October 2026), every PoliNetwork service signs people in through this IdP; no applications still use the backend's removed login (see [Backend login removal](#backend-login-removal)). This repository does not migrate production data or grant Telegram moderation access. ## Run locally @@ -165,29 +165,31 @@ when that is what you mean. ### Permissions the identity provider defines itself Administering this service is expressed as permissions like any other capability, so it can -be delegated to a role instead of being wired to a single group. These ten always exist +be delegated to a role instead of being wired to a single group. These twelve always exist and can never be created, deleted, or rekeyed, because the code checks for these exact keys; which roles carry them is entirely up to you. -| Permission | Covers | -| ------------------------ | ---------------------------------------------------------- | -| `idp:people:read` | Searching the people registered here | -| `idp:users:read` | Browsing the user directory at `/users` | -| `idp:users:delete` | Permanently deleting someone's account | -| `idp:permissions:read` | Seeing permissions in the `/access` section | -| `idp:permissions:write` | Creating, changing, and deleting permissions | -| `idp:roles:read` | Seeing roles, what they grant, and who holds them | -| `idp:roles:write` | Creating, changing, and deleting roles | -| `idp:roles:assign` | Giving roles to people and taking them away | -| `idp:applications:read` | Seeing the OIDC applications at `/applications` | -| `idp:applications:write` | Registering and editing applications, and rotating secrets | +| Permission | Covers | +| ------------------------ | ----------------------------------------------------------------- | +| `idp:people:read` | Searching the people registered here | +| `idp:users:read` | Browsing the user directory at `/users` | +| `idp:users:delete` | Permanently deleting someone's account | +| `idp:permissions:read` | Seeing permissions in the `/access` section | +| `idp:permissions:write` | Creating, changing, and deleting permissions | +| `idp:roles:read` | Seeing roles, what they grant, and who holds them | +| `idp:roles:write` | Creating, changing, and deleting roles | +| `idp:roles:assign` | Giving roles to people and taking them away | +| `idp:people:bind` | Choosing which roles the [People register](#people-roles) manages | +| `idp:applications:read` | Seeing the OIDC applications at `/applications` | +| `idp:applications:write` | Registering and editing applications, and rotating secrets | +| `idp:mcp` | Connecting an AI agent through the [MCP server](#mcp-server) | They use the permission hierarchy themselves: each `write` grants its `read`, `idp:roles:assign` grants `idp:roles:read` and `idp:people:read` so an assigner can see the role and find who to give it to, `idp:roles:write` also grants `idp:people:read`, `idp:users:read` grants `idp:people:read` because browsing everyone includes finding them, and `idp:roles:read` grants `idp:permissions:read` because a role is meaningless without seeing the permissions it carries. A role with `idp:roles:write` therefore ends up with four permissions and still cannot touch applications. `idp:roles:write` granting -`idp:roles:read`, `idp:roles:assign` granting `idp:roles:read`, `idp:permissions:write` +`idp:roles:read`, `idp:roles:assign` and `idp:people:bind` granting `idp:roles:read`, `idp:permissions:write` granting `idp:permissions:read` and `idp:users:delete` granting `idp:users:read` are fixed in code: they apply even if the stored edge is missing, and cannot be removed, because changing either without seeing what already exists makes no sense. The other implications @@ -328,7 +330,7 @@ keys cannot change; renaming a custom role's key breaks policies that match the Managing applications needs the `idp:applications:write` permission, so it can be given to any role. Master Admin holds it only through explicit deployment configuration. To use a Microsoft 365 administrators group distinct from Soci, set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to that group's object ID: only its direct members, checked through the same Graph credentials, keep it. Graph answers are cached for at most 60 seconds from lookup start per user; a failed check denies access instead of caching. `IDP_ADMIN_USER_IDS` remains a break-glass allowlist of local user IDs that always pass. Being a socio never confers administration by itself. -Dynamic registration is disabled. Client-credentials grants are available when both OAuth resource identifiers are configured. Administrators manage every client at `/applications`. New applications go through a step-by-step wizard (Basics, Usage, Authentication, Access, then a review) with a summary that fills in as each step is completed; existing ones are edited on a settings page with a save bar. The app either signs people in, acts as itself (client credentials), or both, and that choice decides the credentials it may use: a client secret (shown once), a private key (a public Ed25519 or P-256 JWKS for `private_key_jwt`), or, for apps that only sign people in, none (public, PKCE only). Redirect URIs, the scopes people are asked for, and the PoliNetwork API scopes granted for signed-in people and for the app itself are chosen separately. Resource links follow the granted API scopes automatically. Everything except the client ID can be changed after creation, including the credential type: switching to a secret issues a new one, shown once, and switching away from a secret or key revokes it. Administrators can also rotate secrets, pause sign-ins by disabling an app, skip the consent screen for first-party apps, and delete apps. All administrators share one client pool (the plugin's `clientReference` is a fixed value), so clients are not tied to whoever created them. Redirect URIs follow the provider's rules, chosen per application as "Public https URLs only" (`application_type: web`) or "Also localhost and app schemes" (`native`: `http://localhost`, `http://127.0.0.1`, `http://[::1]`, or a reverse-domain custom scheme). Server functions in `src/auth/oidc.functions.ts` back the pages, including secret rotation and deletion. API access (backend and `idp:access:read` scopes, the client-credentials grant, and resource links) requires Master Admin, and so does any change to a client that has it. Every change to an existing client checks that rule and writes in one transaction that holds the RBAC write lock, so a writer's rotation or deletion either finishes before a concurrent promotion or is refused after it. The provider's own client mutation endpoints (`/oauth2/create-client`, `/oauth2/update-client`, `/oauth2/delete-client`, `/oauth2/client/rotate-secret`) are disabled over HTTP and refused to server-side callers, because they check and write in separate statements outside that lock; resources are linked only by saving an application. +Dynamic registration is open to AI agents only, for the [MCP server](#mcp-server). Client-credentials grants are available when both OAuth resource identifiers are configured. Administrators manage every client at `/applications`. New applications go through a step-by-step wizard (Basics, Usage, Authentication, Access, then a review) with a summary that fills in as each step is completed; existing ones are edited on a settings page with a save bar. The app either signs people in, acts as itself (client credentials), or both, and that choice decides the credentials it may use: a client secret (shown once), a private key (a public Ed25519 or P-256 JWKS for `private_key_jwt`), or, for apps that only sign people in, none (public, PKCE only). Redirect URIs, the scopes people are asked for, and the PoliNetwork API scopes granted for signed-in people and for the app itself are chosen separately. Resource links follow the granted API scopes automatically. Everything except the client ID can be changed after creation, including the credential type: switching to a secret issues a new one, shown once, and switching away from a secret or key revokes it. Administrators can also rotate secrets, pause sign-ins by disabling an app, skip the consent screen for first-party apps, and delete apps. All administrators share one client pool (the plugin's `clientReference` is a fixed value), so clients are not tied to whoever created them. Redirect URIs follow the provider's rules, chosen per application as "Public https URLs only" (`application_type: web`) or "Also localhost and app schemes" (`native`: `http://localhost`, `http://127.0.0.1`, `http://[::1]`, or a reverse-domain custom scheme). Server functions in `src/auth/oidc.functions.ts` back the pages, including secret rotation and deletion. API access (backend and `idp:access:read` scopes, the client-credentials grant, and resource links) requires Master Admin, and so does any change to a client that has it. Creating a client and every change to an existing one check that rule and write in one transaction that holds the RBAC write lock, so a writer's rotation or deletion either finishes before a concurrent promotion or is refused after it. New clients are inserted directly, in the row format the provider uses, because its admin create endpoint needs a browser session and writes outside the lock. The provider's own client mutation endpoints (`/oauth2/create-client`, `/oauth2/update-client`, `/oauth2/delete-client`, `/oauth2/client/rotate-secret`) are disabled over HTTP and refused to server-side callers, because they check and write in separate statements outside that lock; resources are linked only by saving an application. ### Phase 1 backend access snapshot @@ -342,22 +344,112 @@ Master Admin registers the four Phase 1 clients on `/applications/new`, each wit During a sign-in the login page names the requesting application. The consent page at `/consent` shows the app, who is signed in (with a switch-account option), each requested scope in plain language, any requested profile claims, and where the browser will be sent; it supports allow and deny and explains expired or disabled requests. -Future consumers should use authorization code with PKCE, validate token signatures, issuer, audience, and expiration, and request `polinetwork:identity` only when needed. APIs must check their own permissions. An ID token or a linked Telegram ID alone is not permission to moderate a group. +New services follow [docs/service-integration-guide.md](docs/service-integration-guide.md). Future clients that sign people in should use authorization code with PKCE; service clients use client credentials, as the guide describes. All of them validate token signatures, issuer, audience, and expiration, and request `polinetwork:identity` only when needed. APIs must check their own permissions. An ID token or a linked Telegram ID alone is not permission to moderate a group. -## Migration work before cutover +### People roles -Inspection found backend auth in `../backend/src/auth/index.ts`, with custom email OTP, passkeys, shared subdomain cookies, and custom Telegram linking. `../admin` reads `user.telegramId` and calls backend Telegram permission routes. `../group-bot` also uses those backend assignments. +The admin dashboard's People register gives people predetermined roles: those bound, on the role's page (People register card), to what holding them means in People. A role's holders are admins of the Telegram groups (`admin`), Capo Admins (`capo`), or members or the Lead of a team (`team::member`, `team::lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Binding a role needs `idp:people:bind`, and, as when editing a role, only Master Admin can bind or unbind a role granting permissions they don't hold. Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. Bind only one role to each meaning: `trusted-admin` inherits `admin`, so binding it as `admin` too makes giving `admin` ambiguous (409). -The backend scopes its Better Auth cookies to `.polinetwork.org` with the default `better-auth` prefix, so browsers send them to this service too. This service uses its own `pn-identity` cookie prefix (`src/auth/cookies.ts`) so those cookies cannot shadow its session. Keep the prefixes different while both run. +`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, each with every permission it grants (`permissions`, through parent roles too) and its own binding followed by those it inherits (`bindings`), and everyone holding them by hand or through a parent role, with their name, directory email, Telegram ID and username, which of those roles they were given directly, and since when they hold each one (`since`, `YYYY-MM-DD`: the earliest of their current assignments conferring it). Unlike the access snapshot it includes roles that grant no permission. People without a login account are the backend's own: they never appear here. -Keep these integrations running while testing this service. A later migration needs: +`POST /api/internal/people-roles` (backend client, `idp:people:roles`) applies one People command `{ id, actor, sub, reason, changes }` on behalf of the dashboard user the backend authenticated. `sub` is always a login account. Each change names a desired state: `{ kind: "admin" | "capo", active }` or `{ kind: "team", team, role: "member" | "lead" | null }`. This IdP plans the concrete assignments against its own role graph and applies all of them or none: a concept with no bound role stays People's own and is skipped; it refuses with 409 when the mapping is ambiguous (not exactly one bound role to give), or when the requested state would still be held through another role (ending `admin` for a Capo Admin, say), rather than reporting a change that did not happen. -1. Back up and inventory backend users, accounts, passkeys, Telegram links, roles, and foreign keys. Preserve user IDs or define a reviewed mapping. This prototype's schema is not a replacement migration for the backend's prefixed tables. -2. Implement a bridge that proves ownership of the existing backend session before connecting an existing user here. Existing backend passkeys are not imported; users register new passkeys in this service. The prototype does not preserve email OTP login. Never import Telegram usernames as ownership proof or manufacture OIDC accounts from old links. -3. Require official Telegram re-verification and reconcile the verified numeric ID with existing moderation assignments. Review conflicts instead of merging automatically. -4. Confirm the PN membership rule and choose how often students must reverify their Polimi email. -5. Test a downstream application in staging, including authorization code, consent, refresh, logout, account conflicts, and revoked permissions. The current shared-cookie clients cannot be pointed at this issuer without integration changes. -6. Schedule a separate cutover and rollback plan. Retain the backend login until those checks pass. +The backend only names the actor; this IdP resolves the actor's own access and refuses every assignment, before any write, unless: + +- the actor isn't giving themselves a role (giving one up is allowed). When an invitee accepts an invite, the backend sends whoever made each assignment as the actor; +- the rule for the role's binding, and for every bound role it inherits, allows it (`PEOPLE_RULES` in `src/auth/people-binding.ts`): + + | Binding | Who may give or take it back | + | ------------------- | ------------------------------------------------------------------------ | + | `admin` | `admin:access` + `people:write` | + | `capo` | `admin:access` + `people:appoint` (and, through `admin`, `people:write`) | + | `team::member` | `admin:access` + `people:write`, or the Lead of that team | + | `team::lead` | `admin:access` + `people:appoint` | + +- unless Master Admin, the actor holds every permission the role grants. + +The backend applies the same rules to assignments it keeps for records that aren't linked yet, which this IdP only sees when the record is linked: that is what each bound role's `permissions` and `bindings` are for. + +The response is the person's bound roles after the command (`held`, `assigned`) and the bound role list. Each assignment is audited as `role.assign`/`role.unassign` with `via: "people"`, the reason and the command ID, and the command itself as `people.roles` under `people-command:`: repeating an ID with the same command, for example after a lost response, applies nothing again and returns the current roles, and repeating it with a different command is refused with 409. + +#### Deploying People roles + +Deploy this before the backend and dashboard that use it, then (PoliNetworkOrg/auth#79): + +1. Migrate (`0023_role_people_binding`). It adds the binding column and `idp:people:bind`, which only Master Admin holds until you grant it to a role. +2. Grant `idp:people:bind` to whoever should configure People, if anyone besides Master Admin. +3. Bind the roles. Team keys must match the backend's team list: + + | Role | Binding | + | ------------------------------- | ----------------------------------------- | + | `admin` | `admin` | + | `capo-admin` | `capo` | + | `hr-member` / `hr-lead` | `team:hr:member` / `team:hr:lead` | + | `design-member` / `design-lead` | `team:design:member` / `team:design:lead` | + | `intl-member` / `intl-lead` | `team:intl:member` / `team:intl:lead` | + | `events-member` / `events-lead` | `team:events:member` / `team:events:lead` | + + Leave `trusted-admin` unbound: it inherits `admin` and stays assigned by hand. `it-core` and `it-lead` carry `idp:*` permissions, so they can't be bound; the IT team needs plain team roles of its own (see #79). + +4. Grant the internal scope `idp:people:roles` to the backend client on its application page. Reads work without it. +5. Send one role command from the backend for a test account, and check that the audit log shows `via: people`. + +## MCP server + +`/api/mcp` is an MCP server, so an AI agent such as Claude Code or Codex can read and change this identity provider for you: debug an application that cannot sign people in, register or edit applications, and set up roles, permissions and who holds them. It only works for people holding `idp:mcp`, and the agent can only do what that person could do on the website. + +### Connect an agent + +Your account page shows the server URL and these commands when you hold `idp:mcp`. For production: + +| Agent | Command | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | `claude mcp add --scope user --transport http polinetwork-auth https://auth.polinetwork.org/api/mcp`, then `/mcp` to sign in | +| Codex | `codex mcp add polinetwork-auth --url https://auth.polinetwork.org/api/mcp` (signs in right away) | +| Gemini CLI | `gemini mcp add --scope user --transport http polinetwork-auth https://auth.polinetwork.org/api/mcp` | +| VS Code | `code --add-mcp '{"name":"polinetwork-auth","type":"http","url":"https://auth.polinetwork.org/api/mcp"}'` | + +Claude Desktop, claude.ai and ChatGPT add it as a custom connector with the same URL. The agent opens a browser, you sign in and approve it once, and it stays connected: it refreshes its access every few minutes, survives signing out of this website, and stops after 30 days without use. Cursor does not work yet: it registers a `cursor://` callback that is not a valid app scheme, so sign-in is refused. Agents that run inside a web page, such as the MCP Inspector in direct mode, do not work either: the token endpoint does not answer cross-origin requests. + +### What an agent can do + +Every tool calls the same code as the matching page, which checks the permission again, keeps the bounded delegation rules, takes the RBAC write lock, and writes the audit log under your name. Tools you have no permission for are not offered. + +| Permission | Tools | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `idp:mcp` | `whoami`, `get_server_info` (endpoints, token formats, rules), `inspect_token` (decode and verify a JWT) | +| `idp:applications:read` | `list_applications`, `get_application`, `diagnose_application` (finds bad settings and, given the authorization URL an app sends, says what `/oauth2/authorize` would answer and why) | +| `idp:applications:write` | `create_application`, `update_application`, `rotate_application_secret`, `delete_application` | +| `idp:people:read` | `search_people` | +| `idp:users:read` | `list_users`, `get_user`; with `idp:roles:read` also `explain_access` (why someone holds a permission) | +| `idp:roles:read` | `list_roles`, `get_role` | +| `idp:permissions:read` | `list_permissions` | +| `idp:roles:write` | `create_role`, `update_role`, `delete_role` | +| `idp:roles:assign` | `assign_role`, `unassign_role` | +| `idp:permissions:write` | `create_permission`, `update_permission`, `delete_permission` | +| `idp:people:bind` | `set_role_people_binding` | +| Master Admin | `list_audit_events` | + +Deleting people is left to the website. Deletes ask the agent to repeat the name or key. New client secrets go back to the agent once, like on the page, so it can hand them over. + +### How sign-in works + +The server follows the MCP authorization spec. `/.well-known/oauth-protected-resource/api/mcp` names this identity provider as its authorization server, and the authorization server metadata is also served at `/.well-known/oauth-authorization-server/api/auth` and `/.well-known/openid-configuration/api/auth`, where agents look first. Agents register themselves through dynamic client registration (`/api/auth/oauth2/register`), which is open but only to agents: + +- A registration must redirect to `http://localhost`, `http://127.0.0.1` or `http://[::1]` (any port), or exactly to a hosted agent's callback (`claude.ai`, `claude.com`, `chatgpt.com`, `vscode.dev`). Someone who tricks you into approving their client cannot collect the code on their own machine. +- It may use only the authorization code grant with refresh tokens, as a public client or with a secret, never with keys. A request with a browser session is refused: applications for people are registered from `/applications`. +- Its scopes are limited to `idp:mcp` and `offline_access`, and agents that ask for more get only these. Without `openid` it gets no ID token and cannot call UserInfo. It is linked to the MCP server alone, and a request that names no `resource` gets a token for the MCP server, so its tokens are useless anywhere else. +- Its logo, links and logout addresses are dropped, because PoliNetwork would seem to vouch for them. The consent page shows its name and where it redirects. + +Tokens are EdDSA JWTs addressed to `/api/mcp`, valid five minutes. Every request checks the token, that the agent's client still exists with your consent, and that you still hold `idp:mcp`, so taking the permission away or disconnecting the agent from your account page ends access on the next request. People without `idp:mcp` are stopped on the consent page. Registrations nobody approves within a day are deleted, and so are agents nobody has used for 60 days; each use is recorded on the agent's consent, at most hourly. Registration is rate-limited to five a minute per address. + +## Backend login removal + +The backend's own login (better-auth with email OTP, passkeys and Telegram link codes) was removed in RFC v3 Phase 6: every PoliNetwork service signs people in through this IdP. Browsers may still hold the backend's old `better-auth` cookies on `.polinetwork.org` until they expire; this service's `pn-identity` cookie prefix (`src/auth/cookies.ts`) keeps them from shadowing its session. + +To connect a new service, follow [docs/service-integration-guide.md](docs/service-integration-guide.md). + +Still open: confirm the PN membership rule and how often students must reverify their Polimi email. ## Validation