diff --git a/README.md b/README.md index 627ce94..697bb9a 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,8 @@ 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. -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 | | -------------- | -------------- | --------------------------------------------------------------------------------------------------- | @@ -122,20 +122,27 @@ Their membership is conferred by the identity provider itself: | `Direttivo` | `direttivo` | Direct membership of `PN_ENTRA_DIRETTIVO_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 other three 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 other three 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`. @@ -145,7 +152,7 @@ board's Entra group object ID, nobody is inferred as Direttivo. Both group check 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. diff --git a/drizzle/0024_presidente_segretario_roles.sql b/drizzle/0024_presidente_segretario_roles.sql new file mode 100644 index 0000000..ef00219 --- /dev/null +++ b/drizzle/0024_presidente_segretario_roles.sql @@ -0,0 +1,40 @@ +-- A hand-made role already using one of these keys would silently stand in for the board +-- 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. 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, 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") +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;--> 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/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/src/auth/access-snapshot.ts b/src/auth/access-snapshot.ts index 8f7e60b..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. @@ -271,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 0e28c7e..39021df 100644 --- a/src/auth/account-overview.ts +++ b/src/auth/account-overview.ts @@ -5,7 +5,7 @@ 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"; @@ -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/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/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/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 91bdc47..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, @@ -123,18 +124,25 @@ 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("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", () => { const errors = validateRoleDraft( { key: "socio", name: "Socio", description: "", parents: [], permissions: [] }, @@ -144,6 +152,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..a65238e 100644 --- a/src/auth/rbac.ts +++ b/src/auth/rbac.ts @@ -1,13 +1,15 @@ // 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. + * 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 +17,8 @@ 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.", evidence: @@ -24,6 +28,8 @@ export const STATIC_ROLES = [ key: "socio", state: "socio", grantsAllPermissions: false, + masterAssigned: false, + parents: [], name: "Socio", description: "Member of PoliNetwork APS.", evidence: @@ -33,15 +39,41 @@ export const STATIC_ROLES = [ key: "direttivo", state: "direttivo", grantsAllPermissions: false, + masterAssigned: 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: 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: "Given by hand, and only by a Master Admin.", + }, + { + key: "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: "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.", evidence: "A verification code delivered to the person's @mail.polimi.it address.", @@ -76,6 +108,24 @@ 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 ?? []; +} + /** * 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. @@ -219,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; @@ -525,6 +578,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/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/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/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;