From 3995d96d4fb2b12471909d9bd3af85611868406b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Wed, 21 Oct 2026 20:36:18 +0000 Subject: [PATCH 1/2] feat(rbac): support inherited permission overrides --- README.md | 8 +- drizzle/0022_role_permission_overrides.sql | 1 + drizzle/meta/0022_snapshot.json | 2482 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 2 +- src/auth/rbac-delegation.ts | 4 +- src/auth/rbac-security.integration.test.mjs | 75 + src/auth/rbac-store.ts | 48 +- src/auth/rbac.functions.ts | 1 + src/auth/rbac.test.ts | 155 ++ src/auth/rbac.ts | 112 +- src/components/rbac/pick-list.tsx | 87 +- src/components/rbac/role-form.tsx | 156 +- src/db/rbac.ts | 2 + 14 files changed, 3034 insertions(+), 106 deletions(-) create mode 100644 drizzle/0022_role_permission_overrides.sql create mode 100644 drizzle/meta/0022_snapshot.json diff --git a/README.md b/README.md index 54edaeb..b61b474 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,13 @@ and both support a hierarchy: - A permission can **also grant** other permissions. Holding `membership:write` can grant `membership:read` without listing it everywhere. - A role can **inherit from** other roles. It then carries every permission of its parents, - including what those inherit in turn. + including what those inherit in turn. In the editor, inherited permissions are already + checked and labeled **Inherited**; explicit grants are labeled **Direct**. Unchecking an + inherited or implied permission creates an **Override**, highlighted in amber. **Use + inherited** clears the override. Overrides are saved with the role and follow its + descendants, without changing the parent or sibling roles. Another independently held + role can still grant an excluded permission. Essential read access required by managed + write/assign permissions cannot be excluded while that capability remains enabled. Both hierarchies are transitive, and the editor refuses an edge that would make two roles inherit from each other or two permissions grant each other. diff --git a/drizzle/0022_role_permission_overrides.sql b/drizzle/0022_role_permission_overrides.sql new file mode 100644 index 0000000..2b8c2da --- /dev/null +++ b/drizzle/0022_role_permission_overrides.sql @@ -0,0 +1 @@ +ALTER TABLE "role_permission" ADD COLUMN "granted" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json new file mode 100644 index 0000000..0afa3d0 --- /dev/null +++ b/drizzle/meta/0022_snapshot.json @@ -0,0 +1,2482 @@ +{ + "id": "61ba7fda-65f9-4d38-b389-352c8e28edde", + "prevId": "36d209bc-7901-4b62-9fa6-10bff23ed011", + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_authorizationCodeId_idx": { + "name": "oauthAccessToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_clientId_idx": { + "name": "oauthClientResource_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_resourceId_idx": { + "name": "oauthClientResource_resourceId_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["identifier"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_authorizationCodeId_idx": { + "name": "oauthRefreshToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "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", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "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", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "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", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "where": "\"identity_evidence\".\"telegram_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "studentVerificationChallenge_expiresAt_idx": { + "name": "studentVerificationChallenge_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "student_verification_challenge_user_id_user_id_fk": { + "name": "student_verification_challenge_user_id_user_id_fk", + "tableFrom": "student_verification_challenge", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_implication_permission_id_permission_id_fk": { + "name": "permission_implication_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_implication_implied_permission_id_permission_id_fk": { + "name": "permission_implication_implied_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["implied_permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_parent_role_id_role_id_fk": { + "name": "role_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_parent_parent_role_id_role_id_fk": { + "name": "role_parent_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["parent_role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "granted": { + "name": "granted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "rolePermission_permission_idx": { + "name": "rolePermission_permission_idx", + "columns": [ + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permission_permission_id_permission_id_fk": { + "name": "role_permission_permission_id_permission_id_fk", + "tableFrom": "role_permission", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_role_user_id_user_id_fk": { + "name": "user_role_user_id_user_id_fk", + "tableFrom": "user_role", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_role_role_id_role_id_fk": { + "name": "user_role_role_id_role_id_fk", + "tableFrom": "user_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": { + "type": "always", + "name": "access_outbox_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "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": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d0b6d23..7a473e9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1792061144133, "tag": "0021_passkey_prompt_declined", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1792614505045, + "tag": "0022_role_permission_overrides", + "breakpoints": true } ] } diff --git a/package.json b/package.json index c59357a..fc05a1d 100644 --- a/package.json +++ b/package.json @@ -86,4 +86,4 @@ "*" ] } -} \ No newline at end of file +} diff --git a/src/auth/rbac-delegation.ts b/src/auth/rbac-delegation.ts index dc470dd..3d96e5b 100644 --- a/src/auth/rbac-delegation.ts +++ b/src/auth/rbac-delegation.ts @@ -15,6 +15,8 @@ export function mayDelegateMutation( access: ResolvedAccess, operation: string, targetId: string, + // Expanded roles would independently restore grants excluded by a child role. + heldRoleKeys: readonly string[], ): boolean { if (access.roles.includes(MASTER_ADMIN_ROLE_KEY)) return true; const held = new Set(access.permissions); @@ -40,7 +42,7 @@ export function mayDelegateMutation( if (old && !subset(effectiveRolePermissions(before, old.key))) return false; if (next && !subset(effectiveRolePermissions(after, next.key))) return false; } - if (!subset(resolveAccess(after, access.roles).permissions)) return false; + if (!subset(resolveAccess(after, heldRoleKeys).permissions)) return false; // Custom ancestors/implications cannot act as a backdoor for editing managed or // more privileged roles, including roles the writer does not themselves hold. for (const old of before.roles) { diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs index bbff90b..0ec276a 100644 --- a/src/auth/rbac-security.integration.test.mjs +++ b/src/auth/rbac-security.integration.test.mjs @@ -127,6 +127,81 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre return { id, role }; } + it("persists role overrides through the server function, identity resolution and audit", async () => { + const read = await savePermission(root, draftPermission(unique("read"))); + const write = await savePermission(root, draftPermission(unique("write"), [read.key])); + const parent = await saveRole(root, draftRole(unique("parent"), [write.key])); + const actor = await delegate([]); + const child = await saveRole(root, draftRole(unique("child"), [], [parent.key])); + await assignRole(root, child.id, actor.id); + expect((await getIdentity(actor.id)).permissions).toEqual([read.key, write.key].sort()); + + const response = await call(saveRoleFn, root, { + roleId: child.id, + draft: { ...draftRole(child.key, [], [parent.key]), deniedPermissions: [read.key] }, + }); + expect(response.status).toBe(200); + expect(response.result).toMatchObject({ permissions: [], deniedPermissions: [read.key] }); + expect( + (await loadCatalog(root)).roles.find((entry) => entry.id === child.id).deniedPermissions, + ).toEqual([read.key]); + expect((await getIdentity(actor.id)).permissions).toEqual([write.key]); + expect( + (await pool.query("SELECT granted FROM role_permission WHERE role_id = $1", [child.id])).rows, + ).toEqual([{ granted: false }]); + const audit = await pool.query( + "SELECT after FROM rbac_audit_event WHERE target_id = $1 AND operation = 'role.save' ORDER BY \"createdAt\" DESC LIMIT 1", + [child.id], + ); + expect(audit.rows[0].after.target.deniedPermissions).toEqual([read.key]); + + const restored = await call(saveRoleFn, root, { + roleId: child.id, + draft: { ...draftRole(child.key, [], [parent.key]), deniedPermissions: [] }, + }); + expect(restored.status).toBe(200); + expect(restored.result.deniedPermissions).toEqual([]); + expect((await getIdentity(actor.id)).permissions).toEqual([read.key, write.key].sort()); + const invalid = await call(saveRoleFn, root, { + roleId: child.id, + draft: { ...draftRole(child.key, [read.key], [parent.key]), deniedPermissions: [read.key] }, + }); + expect(invalid.status).toBe(400); + expect((await getIdentity(actor.id)).permissions).toEqual([read.key, write.key].sort()); + }); + + it("does not let a delegated writer remove an override to gain permissions", async () => { + const high = await savePermission(root, draftPermission(unique("high"))); + const parent = await saveRole(root, draftRole(unique("parent"), [high.key])); + const actor = await delegate([]); + const limited = await saveRole(root, { + ...draftRole(unique("limited"), ["idp:roles:write"], [parent.key]), + deniedPermissions: [high.key], + }); + await assignRole(root, limited.id, actor.id); + const unchanged = await call(saveRoleFn, actor.id, { + roleId: limited.id, + draft: { + ...draftRole(limited.key, limited.permissions, limited.parents), + deniedPermissions: [high.key], + description: "Still limited", + }, + }); + expect(unchanged.status).toBe(200); + const escalation = await call(saveRoleFn, actor.id, { + roleId: limited.id, + draft: { + ...draftRole(limited.key, limited.permissions, limited.parents), + deniedPermissions: [], + }, + }); + expect(escalation.status).toBe(403); + expect((await getIdentity(actor.id)).permissions).not.toContain(high.key); + expect( + (await loadCatalog(root)).roles.find((entry) => entry.id === limited.id).deniedPermissions, + ).toEqual([high.key]); + }); + it("denies creating a privileged role, self-assigning it, and editing one's own role", async () => { const actor = await delegate(["idp:roles:write", "idp:roles:assign"]); const high = await saveRole(root, draftRole(unique("high"), ["idp:applications:write"])); diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts index a899e84..953751f 100644 --- a/src/auth/rbac-store.ts +++ b/src/auth/rbac-store.ts @@ -62,7 +62,11 @@ export async function readCatalog(db: CatalogReader): Promise { const roles = await db.select().from(role).orderBy(role.key); const permissions = await db.select().from(permission).orderBy(permission.key); const rolePermissions = await db - .select({ roleId: rolePermission.roleId, permissionKey: permission.key }) + .select({ + roleId: rolePermission.roleId, + permissionKey: permission.key, + granted: rolePermission.granted, + }) .from(rolePermission) .innerJoin(permission, eq(permission.id, rolePermission.permissionId)); const roleParents = await db @@ -90,7 +94,12 @@ export async function readCatalog(db: CatalogReader): Promise { }; const permissionsByRole = collect( - rolePermissions, + rolePermissions.filter((row) => row.granted), + (row) => row.roleId, + (row) => row.permissionKey, + ); + const deniedByRole = collect( + rolePermissions.filter((row) => !row.granted), (row) => row.roleId, (row) => row.permissionKey, ); @@ -106,7 +115,7 @@ export async function readCatalog(db: CatalogReader): Promise { ); const memberCounts = new Map(members.map((row) => [row.roleId, row.members])); const rolesPerPermission = new Map(); - for (const row of rolePermissions) + for (const row of rolePermissions.filter((row) => row.granted)) rolesPerPermission.set(row.permissionKey, (rolesPerPermission.get(row.permissionKey) ?? 0) + 1); return { @@ -118,6 +127,7 @@ export async function readCatalog(db: CatalogReader): Promise { managed: row.managed, sourceState: row.sourceState, permissions: permissionsByRole.get(row.id) ?? [], + deniedPermissions: deniedByRole.get(row.id) ?? [], parents: parentsByRole.get(row.id) ?? [], memberCount: memberCounts.get(row.id) ?? 0, createdAt: row.createdAt?.toISOString() ?? null, @@ -185,7 +195,12 @@ export async function withAuthorizedRbacWrite( ManagedPermissionKey, `${string}:write` | `${string}:assign` | `${string}:delete` >, - change: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise, + change: ( + transaction: Transaction, + catalog: RbacCatalog, + access: ResolvedAccess, + heldRoleKeys: string[], + ) => Promise, ): Promise { await refreshIdentityMembership(actorId); return db.transaction( @@ -193,15 +208,16 @@ export async function withAuthorizedRbacWrite( await transaction.execute(authorizationMutationLock); const subject = await readIdentitySubject(actorId, transaction); const catalog = await readCatalog(transaction); - const access = resolveAccess(catalog, [ + const heldRoleKeys = [ ...(await assignedRoleKeys(actorId, catalog, transaction)), ...subject.roleKeys, - ]); + ]; + const access = resolveAccess(catalog, heldRoleKeys); if (!access.permissions.includes(required)) { logAuthorizationDenial(actorId, "rbac-store", [required]); throw new RbacError(403, "You do not have permission to do that."); } - return change(transaction, catalog, access); + return change(transaction, catalog, access, heldRoleKeys); }, { isolationLevel: "read committed" }, ); @@ -253,11 +269,11 @@ async function withRbacWriteLock( return withAuthorizedRbacWrite( actorId, requiredForOperation(operation), - async (transaction, catalog, access) => { + async (transaction, catalog, access, heldRoleKeys) => { const before = await auditSnapshot(transaction, catalog, operation, targetId); const result = await change(transaction, catalog, access); const next = await readCatalog(transaction); - if (!mayDelegateMutation(catalog, next, access, operation, targetId)) { + if (!mayDelegateMutation(catalog, next, access, operation, targetId, heldRoleKeys)) { logAuthorizationDenial(actorId, "rbac-store", ["bounded-delegation"]); throw new RbacError( 403, @@ -377,7 +393,9 @@ export async function saveRole( // Master Admin already holds everything, so a stored grant list would only mislead. if ( current?.key === MASTER_ADMIN_ROLE_KEY && - (draft.permissions.length > 0 || draft.parents.length > 0) + (draft.permissions.length > 0 || + draft.parents.length > 0 || + (draft.deniedPermissions?.length ?? 0) > 0) ) throw new RbacError( 400, @@ -386,6 +404,7 @@ export async function saveRole( checked(validateRoleDraft(draft, { catalog, currentKey: current?.key })); const id = targetId; const permissionIds = idsForPermissionKeys(catalog, draft.permissions); + const deniedIds = idsForPermissionKeys(catalog, draft.deniedPermissions ?? []); const parentIds = idsForRoleKeys(catalog, draft.parents); const values = { key: draft.key, @@ -396,10 +415,11 @@ export async function saveRole( if (current) await transaction.update(role).set(values).where(eq(role.id, id)); else await transaction.insert(role).values({ id, managed: false, ...values }); await transaction.delete(rolePermission).where(eq(rolePermission.roleId, id)); - if (permissionIds.length) - await transaction - .insert(rolePermission) - .values(permissionIds.map((permissionId) => ({ roleId: id, permissionId }))); + const grants = [ + ...permissionIds.map((permissionId) => ({ roleId: id, permissionId, granted: true })), + ...deniedIds.map((permissionId) => ({ roleId: id, permissionId, granted: false })), + ]; + if (grants.length) await transaction.insert(rolePermission).values(grants); await transaction.delete(roleParent).where(eq(roleParent.roleId, id)); if (parentIds.length) await transaction diff --git a/src/auth/rbac.functions.ts b/src/auth/rbac.functions.ts index e35d9fb..1207a97 100644 --- a/src/auth/rbac.functions.ts +++ b/src/auth/rbac.functions.ts @@ -25,6 +25,7 @@ const roleDraftSchema = z.object({ description: z.string(), parents: z.array(z.string()).max(50), permissions: z.array(z.string()).max(200), + deniedPermissions: z.array(z.string()).max(200).default([]), }); /** Creates a role, or changes one when `roleId` is given. */ diff --git a/src/auth/rbac.test.ts b/src/auth/rbac.test.ts index ae0166a..0a9a774 100644 --- a/src/auth/rbac.test.ts +++ b/src/auth/rbac.test.ts @@ -14,6 +14,10 @@ import { isStaticRoleKey, resolveAccess, roleParentWouldCycle, + roleDraftFrom, + roleDraftPermissions, + toggleRoleDraftPermission, + normalizeRoleDraft, staticRolesForStates, validatePermissionDraft, validateRoleDraft, @@ -394,3 +398,154 @@ describe("administration catalog visibility", () => { expect(visible).toEqual(catalog); }); }); + +describe("role-local permission overrides", () => { + const excluded = { ...role("limited", [], ["chair"]), deniedPermissions: ["membership:read"] }; + const graph: RbacCatalog = { + ...catalog, + roles: [...catalog.roles, excluded, role("descendant", [], ["limited"])], + }; + + it("excludes a transitive inherited grant after permission implications", () => { + expect(effectiveRolePermissions(graph, "limited")).toEqual(["bank:sign", "membership:write"]); + expect(effectiveRolePermissions(graph, "descendant")).toEqual([ + "bank:sign", + "membership:write", + ]); + expect(effectiveRolePermissions(graph, "chair")).toContain("membership:read"); + expect(resolveAccess(graph, ["limited"]).roles).toEqual([ + "chair", + "direttivo", + "limited", + "socio", + ]); + }); + + it("unions independent roles without applying one role's exclusions to another", () => { + expect(resolveAccess(graph, ["limited", "socio"]).permissions).toContain("membership:read"); + expect(resolveAccess(graph, ["socio", "limited"]).permissions).toContain("membership:read"); + expect( + effectiveRolePermissions( + { ...graph, roles: [...graph.roles, role("sibling", [], ["chair"])] }, + "sibling", + ), + ).toContain("membership:read"); + }); + + it("lets a child explicitly grant a permission excluded by its parent", () => { + const restored = { + ...graph, + roles: [...graph.roles, role("restored", ["membership:read"], ["limited"])], + }; + expect(effectiveRolePermissions(restored, "restored")).toContain("membership:read"); + }); + + it("does not let a second parent resurrect an explicit child exclusion", () => { + const multiple = { + ...graph, + roles: [...catalog.roles, { ...excluded, parents: ["chair", "socio"] }], + }; + expect(effectiveRolePermissions(multiple, "limited")).not.toContain("membership:read"); + }); + + it("preselects inherited and implied permissions without storing direct grants", () => { + const draft = roleDraftFrom(role("child", [], ["chair"])); + expect(roleDraftPermissions(catalog, draft)).toEqual([ + "bank:sign", + "membership:read", + "membership:write", + ]); + expect(draft.permissions).toEqual([]); + }); + + it("turns unchecking an inherited permission into an override and checking it back into inheritance", () => { + const draft = roleDraftFrom(role("child", [], ["chair"])); + const unchecked = toggleRoleDraftPermission(catalog, draft, "membership:read", false); + expect(unchecked.permissions).toEqual([]); + expect(unchecked.deniedPermissions).toEqual(["membership:read"]); + expect(roleDraftPermissions(catalog, unchecked)).not.toContain("membership:read"); + const checked = toggleRoleDraftPermission(catalog, unchecked, "membership:read", true); + expect(checked.deniedPermissions).toEqual([]); + expect(checked.permissions).toEqual([]); + expect(roleDraftPermissions(catalog, checked)).toContain("membership:read"); + }); + + it("continues adding and removing direct grants without creating exclusions", () => { + const draft = roleDraftFrom(role("child", [], ["socio"])); + const added = toggleRoleDraftPermission(catalog, draft, "bank:sign", true); + expect(added.permissions).toEqual(["bank:sign"]); + expect(toggleRoleDraftPermission(catalog, added, "bank:sign", false)).toEqual(draft); + expect(roleDraftPermissions(catalog, added)).toEqual(["bank:sign", "membership:read"]); + }); + + it("excludes a permission granted both directly and through inheritance in a single toggle", () => { + const draft = roleDraftFrom(role("child", ["membership:read"], ["socio"])); + const unchecked = toggleRoleDraftPermission(catalog, draft, "membership:read", false); + expect(unchecked.permissions).toEqual([]); + expect(unchecked.deniedPermissions).toEqual(["membership:read"]); + }); + + it("can exclude an implied permission without removing its independent granting permission", () => { + const draft = roleDraftFrom(role("child", ["membership:write"])); + const unchecked = toggleRoleDraftPermission(catalog, draft, "membership:read", false); + expect(roleDraftPermissions(catalog, unchecked)).toEqual(["membership:write"]); + }); + + it("preserves explicit exclusions when parents change and applies them to future grants", () => { + const draft = roleDraftFrom(excluded); + expect(roleDraftPermissions(catalog, { ...draft, parents: [] })).toEqual([]); + expect(roleDraftPermissions(catalog, { ...draft, parents: ["socio"] })).toEqual([]); + expect(roleDraftPermissions(catalog, { ...draft, deniedPermissions: [] })).toContain( + "membership:read", + ); + }); + + it("normalizes and preserves stored overrides when a role is reopened", () => { + const draft = normalizeRoleDraft({ + ...roleDraftFrom(excluded), + deniedPermissions: ["membership:read", "membership:read"], + }); + expect(draft.deniedPermissions).toEqual(["membership:read"]); + expect(roleDraftPermissions(graph, draft, excluded.key)).toEqual( + effectiveRolePermissions(graph, excluded.key), + ); + }); + + it("rejects unknown exclusions and conflicting grants", () => { + const draft = roleDraftFrom(excluded); + expect( + validateRoleDraft( + { ...draft, deniedPermissions: ["gone"] }, + { catalog: graph, currentKey: excluded.key }, + ).permissions, + ).toBeTruthy(); + expect( + validateRoleDraft( + { ...draft, permissions: ["membership:read"] }, + { catalog: graph, currentKey: excluded.key }, + ).permissions, + ).toBeTruthy(); + }); + + it("preserves intrinsic read access and rejects an override that would break it", () => { + const managedGraph = { + roles: [role("editor", ["idp:roles:write"])], + permissions: MANAGED_PERMISSIONS.map((entry) => + permission(entry.key, [...entry.implies], true), + ), + }; + const draft = { + ...roleDraftFrom(role("child", [], ["editor"])), + deniedPermissions: ["idp:roles:read"], + }; + expect(roleDraftPermissions(managedGraph, draft)).toContain("idp:roles:read"); + expect(validateRoleDraft(draft, { catalog: managedGraph }).permissions).toBeTruthy(); + }); + + it("keeps Master Admin's wildcard independent from exclusions on another held role", () => { + const masterGraph = { ...graph, roles: [...graph.roles, role(MASTER_ADMIN_ROLE_KEY)] }; + expect(resolveAccess(masterGraph, ["limited", MASTER_ADMIN_ROLE_KEY]).permissions).toContain( + "membership:read", + ); + }); +}); diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts index 4c56318..ab8d0ba 100644 --- a/src/auth/rbac.ts +++ b/src/auth/rbac.ts @@ -208,6 +208,8 @@ export type RoleSummary = { sourceState: string | null; /** Keys of the permissions granted directly, before inheritance. */ permissions: string[]; + /** Explicit exclusions applied after inheritance and permission implications. */ + deniedPermissions?: string[]; /** Keys of the roles this role inherits permissions from. */ parents: string[]; /** People holding a hand-made assignment. Always 0 for managed roles. */ @@ -290,22 +292,37 @@ export function grantsAllPermissions(roleKeys: readonly string[]) { } /** - * Turns the roles a person holds into the access they actually have: roles expand up the - * role hierarchy, then the permissions those roles carry expand down the permission - * hierarchy. Keys that no longer exist in the catalog are dropped. - * - * Master Admin is a wildcard rather than a stored list, so it keeps covering permissions - * created after it was last edited. Nothing may inherit from it (see `validateRoleDraft`), - * and resolution ignores legacy inheritance edges to it as well. Only a directly conferred - * Master Admin role activates the wildcard. + * Resolve each directly held role independently, then union their permissions. A child's + * exclusions override all of its parents, but never another independently held role. + * Parent results are already expanded: expanding again would resurrect excluded grants. + * Only a directly held Master Admin activates the wildcard. Cycles terminate defensively. */ export function resolveAccess(catalog: RbacCatalog, roleKeys: Iterable): ResolvedAccess { + const held = [...roleKeys]; const index = indexCatalog(catalog); - const roles = expandRoleKeys(catalog, roleKeys); - if (grantsAllPermissions(roles)) + const roles = expandRoleKeys(catalog, held); + if (grantsAllPermissions(held.filter((key) => index.roles.has(key)))) return { roles, permissions: catalog.permissions.map((entry) => entry.key).sort() }; - const granted = roles.flatMap((key) => index.roles.get(key)?.permissions ?? []); - return { roles, permissions: expandPermissionKeys(catalog, granted) }; + const memo = new Map(); + const visiting = new Set(); + const resolve = (key: string): string[] => { + if (memo.has(key)) return memo.get(key)!; + const role = index.roles.get(key); + if (!role || visiting.has(key) || key === MASTER_ADMIN_ROLE_KEY) return []; + visiting.add(key); + const inherited = role.parents.flatMap(resolve); + const denied = new Set(role.deniedPermissions ?? []); + const granted = [ + ...new Set([...expandPermissionKeys(catalog, role.permissions), ...inherited]), + ].filter((permission) => !denied.has(permission)); + // Managed write/assign capabilities always retain their essential read access. + const required = closure(granted, intrinsicImplications); + const result = [...required].filter((permission) => index.permissions.has(permission)).sort(); + visiting.delete(key); + memo.set(key, result); + return result; + }; + return { roles, permissions: [...new Set(held.flatMap(resolve))].sort() }; } /** Everything a single role confers, for explaining inheritance in the admin UI. */ @@ -313,6 +330,54 @@ export function effectiveRolePermissions(catalog: RbacCatalog, roleKey: string): return resolveAccess(catalog, [roleKey]).permissions; } +/** Resolve a form draft without changing the catalog or its current role. */ +export function roleDraftPermissions(catalog: RbacCatalog, draft: RoleDraft, currentKey?: string) { + // Role keys cannot contain a colon, so the preview key cannot collide. + const key = "draft:preview"; + return resolveAccess( + { + ...catalog, + roles: [ + ...catalog.roles.filter((role) => role.key !== currentKey), + { + id: key, + key, + name: draft.name, + description: null, + managed: false, + sourceState: null, + permissions: draft.permissions, + deniedPermissions: draft.deniedPermissions ?? [], + parents: draft.parents, + memberCount: 0, + createdAt: null, + updatedAt: null, + }, + ], + }, + [key], + ).permissions; +} + +/** Toggle one effective checkbox while retaining inheritance as the default. */ +export function toggleRoleDraftPermission( + catalog: RbacCatalog, + draft: RoleDraft, + key: string, + checked: boolean, + currentKey?: string, +): RoleDraft { + const next = { + ...draft, + permissions: draft.permissions.filter((permission) => permission !== key), + deniedPermissions: (draft.deniedPermissions ?? []).filter((permission) => permission !== key), + }; + const inheritedOrImplied = roleDraftPermissions(catalog, next, currentKey).includes(key); + if (checked && !inheritedOrImplied) next.permissions.push(key); + if (!checked && inheritedOrImplied) next.deniedPermissions.push(key); + return next; +} + /** * Whether pointing `from` at `to` would close a loop in a hierarchy. Used before writing a * role parent or a permission implication so stored edges stay acyclic. @@ -360,6 +425,7 @@ export type RoleDraft = { description: string; parents: string[]; permissions: string[]; + deniedPermissions?: string[]; }; export type RbacDraftErrors = { @@ -376,7 +442,14 @@ export function emptyPermissionDraft(): PermissionDraft { } export function emptyRoleDraft(): RoleDraft { - return { key: "", name: "", description: "", parents: [], permissions: [] }; + return { + key: "", + name: "", + description: "", + parents: [], + permissions: [], + deniedPermissions: [], + }; } export function permissionDraftFrom(permission: PermissionSummary): PermissionDraft { @@ -395,6 +468,7 @@ export function roleDraftFrom(role: RoleSummary): RoleDraft { description: role.description ?? "", parents: [...role.parents], permissions: [...role.permissions], + deniedPermissions: [...(role.deniedPermissions ?? [])], }; } @@ -414,6 +488,7 @@ export function normalizeRoleDraft(draft: RoleDraft): RoleDraft { description: draft.description.trim(), parents: [...new Set(draft.parents)].sort(), permissions: [...new Set(draft.permissions)].sort(), + deniedPermissions: [...new Set(draft.deniedPermissions ?? [])].sort(), }; } @@ -491,8 +566,17 @@ export function validateRoleDraft( const key = draft.key.trim().toLowerCase(); if (!currentKey && isStaticRoleKey(key)) errors.key = "This key belongs to a role the identity provider defines itself."; - if (draft.permissions.some((permission) => !index.permissions.has(permission))) + const denied = draft.deniedPermissions ?? []; + if ([...draft.permissions, ...denied].some((permission) => !index.permissions.has(permission))) errors.permissions = "One of the selected permissions no longer exists."; + else if (denied.some((permission) => draft.permissions.includes(permission))) + errors.permissions = "A permission cannot be both granted and excluded."; + else { + const effective = roleDraftPermissions(catalog, draft, currentKey); + if (denied.some((permission) => effective.includes(permission))) + errors.permissions = + "Remove the write or assign permission before excluding its required read access."; + } if (draft.parents.some((parent) => !index.roles.has(parent))) errors.parents = "One of the selected roles no longer exists."; // Master Admin's wildcard is conferred by the deployment's configuration and is never diff --git a/src/components/rbac/pick-list.tsx b/src/components/rbac/pick-list.tsx index f1ae725..3fa77d0 100644 --- a/src/components/rbac/pick-list.tsx +++ b/src/components/rbac/pick-list.tsx @@ -10,6 +10,9 @@ export type PickOption = { hint?: string; disabled?: boolean; disabledReason?: string; + status?: "Inherited" | "Direct" | "Override" | "Implied"; + statusHint?: string; + action?: { label: string; onClick: () => void; disabled?: boolean }; }; const SEARCHABLE_FROM = 8; @@ -82,31 +85,75 @@ export function PickList({ visible.map((option) => { const checked = selected.includes(option.key); return ( - + {option.action && ( + + )} + ); }) )} diff --git a/src/components/rbac/role-form.tsx b/src/components/rbac/role-form.tsx index 55a8628..682bfa9 100644 --- a/src/components/rbac/role-form.tsx +++ b/src/components/rbac/role-form.tsx @@ -9,11 +9,14 @@ import { type RoleDraft, hasDraftErrors, normalizeRoleDraft, - resolveAccess, + roleDraftPermissions, + effectiveRolePermissions, + intrinsicImplications, + toggleRoleDraftPermission, roleParentWouldCycle, validateRoleDraft, } from "@/auth/rbac"; -import { Field, KeyChip } from "@/components/rbac/fields"; +import { Field } from "@/components/rbac/fields"; import { useAccess } from "@/components/access"; import { canGrantPermission, canGrantRole } from "@/components/rbac/delegation"; import { PickList } from "@/components/rbac/pick-list"; @@ -22,12 +25,6 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -/** - * Stands in for the role being edited while previewing its effect. Role keys cannot contain - * a colon, so this can never collide with a stored role. - */ -const PREVIEW_ROLE_KEY = "draft:preview"; - export function RoleForm({ mode, initial, @@ -92,43 +89,85 @@ export function RoleForm({ }; }); + const preview = roleDraftPermissions(catalog, draft, currentKey); + const inherited = roleDraftPermissions( + catalog, + { + ...draft, + permissions: [], + deniedPermissions: [], + }, + currentKey, + ); + const denied = draft.deniedPermissions ?? []; const permissionOptions = catalog.permissions .filter( (entry) => - canGrantPermission(access, catalog, entry.key) || draft.permissions.includes(entry.key), + canGrantPermission(access, catalog, entry.key) || + preview.includes(entry.key) || + denied.includes(entry.key), ) - .map((entry) => ({ - key: entry.key, - label: entry.name, - hint: entry.description ?? undefined, - disabled: !canGrantPermission(access, catalog, entry.key), - disabledReason: "You do not hold this permission or everything it grants.", - })); - - // Everything a holder would end up with once both hierarchies are followed. - const preview = resolveAccess( - { - ...catalog, - roles: [ - ...catalog.roles.filter((entry) => entry.key !== (currentKey ?? "")), - { - id: PREVIEW_ROLE_KEY, - key: PREVIEW_ROLE_KEY, - name: draft.name, - description: null, - managed: false, - sourceState: null, - permissions: draft.permissions, - parents: draft.parents, - memberCount: 0, - createdAt: null, - updatedAt: null, - }, - ], - }, - [PREVIEW_ROLE_KEY], - ); - const inherited = preview.permissions.filter((key) => !draft.permissions.includes(key)); + .map((entry) => { + const direct = draft.permissions.includes(entry.key); + const excluded = denied.includes(entry.key); + const fromParents = inherited.includes(entry.key); + const sources = catalog.roles + .filter( + (role) => + draft.parents.includes(role.key) && + effectiveRolePermissions(catalog, role.key).includes(entry.key), + ) + .map((role) => role.name); + const requiredBy = catalog.permissions + .filter( + (permission) => + preview.includes(permission.key) && + intrinsicImplications(permission.key).includes(entry.key), + ) + .map((permission) => permission.name); + const outsideAuthority = !canGrantPermission(access, catalog, entry.key); + return { + key: entry.key, + label: entry.name, + hint: entry.description ?? undefined, + status: excluded + ? ("Override" as const) + : direct + ? ("Direct" as const) + : fromParents + ? ("Inherited" as const) + : preview.includes(entry.key) + ? ("Implied" as const) + : undefined, + statusHint: excluded + ? requiredBy.length > 0 + ? "This override is inactive because another permission requires this access. Reset the override or remove that permission." + : fromParents + ? `Excluded for this role. Inherited from ${sources.join(", ")}.` + : "Excluded for this role, including future inheritance." + : fromParents + ? `${direct ? "Also inherited" : "Inherited"} from ${sources.join(", ")}.` + : !direct && preview.includes(entry.key) + ? "Granted by another permission of this role." + : undefined, + disabled: outsideAuthority || requiredBy.length > 0, + disabledReason: outsideAuthority + ? "You do not hold this permission or everything it grants." + : `Required by ${requiredBy.join(", ")}. Remove that permission first.`, + action: + excluded || (direct && fromParents) + ? { + label: fromParents ? "Use inherited" : "Reset override", + disabled: outsideAuthority, + onClick: () => + change({ + permissions: draft.permissions.filter((key) => key !== entry.key), + deniedPermissions: denied.filter((key) => key !== entry.key), + }), + } + : undefined, + }; + }); return (
change({ permissions })} + selected={preview} + onChange={(permissions) => { + const key = + preview.find((key) => !permissions.includes(key)) ?? + permissions.find((key) => !preview.includes(key)); + if (key) { + const next = toggleRoleDraftPermission( + catalog, + draft, + key, + permissions.includes(key), + currentKey, + ); + change({ + permissions: next.permissions, + deniedPermissions: next.deniedPermissions, + }); + } + }} emptyText="Create a permission first." error={errors.permissions} /> change({ parents })} @@ -238,16 +294,6 @@ export function RoleForm({ )} - {inherited.length > 0 && !grantsEverything && ( -
-

Also carries, through inheritance

-

- {inherited.map((key) => ( - {key} - ))} -

-
- )} {readOnly ? null : (
diff --git a/src/db/rbac.ts b/src/db/rbac.ts index 38411af..9dd64b7 100644 --- a/src/db/rbac.ts +++ b/src/db/rbac.ts @@ -100,6 +100,8 @@ export const rolePermission = pgTable( permissionId: text("permission_id") .notNull() .references(() => permission.id, { onDelete: "cascade" }), + // False is a role-local override of an inherited or implied permission. + granted: boolean().default(true).notNull(), }, (table) => [ primaryKey({ columns: [table.roleId, table.permissionId] }), From b9ba3b69b0bb2999017e48134e971c29b9961a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Wed, 21 Oct 2026 20:58:57 +0000 Subject: [PATCH 2/2] feat(rbac): count inherited role members --- README.md | 6 ++ src/auth/rbac-security.integration.test.mjs | 71 +++++++++++++++++++++ src/auth/rbac-store.ts | 47 +++++++++++--- src/auth/rbac.ts | 6 +- src/components/rbac/role-members.tsx | 28 ++++++-- src/routes/access/roles/$roleId.tsx | 2 +- 6 files changed, 144 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b61b474..18e9335 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,12 @@ and both support a hierarchy: role can still grant an excluded permission. Essential read access required by managed write/assign permissions cannot be excluded while that capability remains enabled. +Role member counts and lists include people assigned to descendant roles, once per person. +For example, someone assigned Trusted Admin also counts under Admin when Trusted Admin +inherits from Admin. Inherited members show their source roles; remove an assignment on +that source role to revoke the inherited membership. This follows hand-made assignments; +managed memberships continue to be determined from identity evidence at sign-in. + Both hierarchies are transitive, and the editor refuses an edge that would make two roles inherit from each other or two permissions grant each other. diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs index 0ec276a..c9e7675 100644 --- a/src/auth/rbac-security.integration.test.mjs +++ b/src/auth/rbac-security.integration.test.mjs @@ -536,6 +536,77 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre await expect(listRoleMembers(actor.id, actor.role.id)).rejects.toMatchObject({ status: 403 }); }); + it("counts and lists inherited members once across direct, transitive and diamond paths", async () => { + const admin = await saveRole(root, draftRole(unique("admin"))); + const trusted = await saveRole(root, draftRole(unique("trusted-admin"), [], [admin.key])); + const senior = await saveRole(root, draftRole(unique("senior-admin"), [], [trusted.key])); + const alternate = await saveRole(root, draftRole(unique("alternate-admin"), [], [admin.key])); + const alice = await delegate([]); + const bob = await delegate([]); + for (const role of [admin, trusted, senior, alternate]) + await assignRole(root, role.id, alice.id); + await assignRole(root, trusted.id, bob.id); + + const countFor = async (role) => + (await loadCatalog(root)).roles.find((entry) => entry.id === role.id).memberCount; + expect(await countFor(admin)).toBe(2); + expect(await countFor(trusted)).toBe(2); + expect(await countFor(senior)).toBe(1); + const response = await call(getRoleMembers, root, { roleId: admin.id }); + expect(response.status).toBe(200); + expect(response.result.members).toHaveLength(2); + expect(response.result.members.find((member) => member.userId === alice.id)).toMatchObject({ + direct: true, + assignedBy: root, + inheritedFrom: [trusted.key, senior.key, alternate.key].sort(), + }); + expect(response.result.members.find((member) => member.userId === bob.id)).toMatchObject({ + direct: false, + assignedBy: null, + assignedAt: null, + inheritedFrom: [trusted.key], + }); + expect(await searchUsers(root, bob.id, admin.id)).toEqual([ + expect.objectContaining({ id: bob.id, holdsRole: true }), + ]); + + await unassignRole(root, admin.id, alice.id); + expect(await countFor(admin)).toBe(2); + expect( + (await listRoleMembers(root, admin.id)).members.find((member) => member.userId === alice.id) + .direct, + ).toBe(false); + await saveRole(root, draftRole(trusted.key), trusted.id); + expect(await countFor(admin)).toBe(1); + expect((await listRoleMembers(root, admin.id)).members.map((member) => member.userId)).toEqual([ + alice.id, + ]); + await deleteRole(root, alternate.id); + expect(await countFor(admin)).toBe(0); + expect((await listRoleMembers(root, admin.id)).members).toEqual([]); + }); + + it("terminates inherited membership cycles without double-counting", async () => { + const parent = await saveRole(root, draftRole(unique("cycle-parent"))); + const child = await saveRole(root, draftRole(unique("cycle-child"), [], [parent.key])); + const actor = await delegate([]); + await assignRole(root, child.id, actor.id); + const master = (await loadCatalog(root)).roles.find((entry) => entry.key === "master-admin"); + await pool.query("INSERT INTO role_parent (role_id, parent_role_id) VALUES ($1, $2)", [ + parent.id, + child.id, + ]); + const catalog = await loadCatalog(root); + expect(catalog.roles.find((entry) => entry.id === parent.id).memberCount).toBe(1); + expect(catalog.roles.find((entry) => entry.id === child.id).memberCount).toBe(1); + expect(catalog.roles.find((entry) => entry.id === master.id).memberCount).toBe(0); + expect((await listRoleMembers(root, parent.id)).members).toHaveLength(1); + await pool.query( + "DELETE FROM role_parent WHERE role_id = $1 OR (role_id = $2 AND parent_role_id = $3)", + [parent.id, child.id, master.id], + ); + }); + it("pages every member beyond the former 500-person cutoff without duplicates", async () => { const role = await saveRole(root, draftRole(unique("paged"))); const prefix = unique("paged-person"); diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts index 953751f..d4cf09f 100644 --- a/src/auth/rbac-store.ts +++ b/src/auth/rbac-store.ts @@ -5,7 +5,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, exists, gt, ilike, or, sql } from "drizzle-orm"; +import { and, countDistinct, eq, exists, gt, ilike, or, sql } from "drizzle-orm"; import { db } from "../db/index"; import { authorizationMutationLock } from "../db/security-lock"; import { @@ -56,6 +56,31 @@ type CatalogReader = Pick; type Transaction = Parameters[0]>[0]; +/** + * Expand hand-made assignments through the role hierarchy. UNION deduplicates paths and + * terminates even if legacy edges contain a cycle. Keep the source role to explain inherited + * membership without treating it as a removable assignment on the parent. + * Managed memberships come from identity evidence, never from user_role. + */ +const effectiveRoleAssignments = sql`( + with recursive membership_paths(user_id, role_id, source_role_id) as ( + select ${userRole.userId}, ${userRole.roleId}, ${userRole.roleId} + from ${userRole} + inner join ${role} on ${role.id} = ${userRole.roleId} + where not ${role.managed} + union + select paths.user_id, ${roleParent.parentRoleId}, paths.source_role_id + from membership_paths paths + inner join ${roleParent} on ${roleParent.roleId} = paths.role_id + inner join ${role} parent on parent.id = ${roleParent.parentRoleId} + where parent.key <> ${MASTER_ADMIN_ROLE_KEY} + ) + select * from membership_paths +) membership`; +const membershipUserId = sql`membership.user_id`; +const membershipRoleId = sql`membership.role_id`; +const membershipSourceRoleId = sql`membership.source_role_id`; + // Read one query at a time: `db` may be an open transaction, and a transaction is a single // PostgreSQL session that cannot run overlapping queries. export async function readCatalog(db: CatalogReader): Promise { @@ -78,9 +103,9 @@ export async function readCatalog(db: CatalogReader): Promise { .from(permissionImplication) .innerJoin(permission, eq(permission.id, permissionImplication.impliedPermissionId)); const members = await db - .select({ roleId: userRole.roleId, members: count() }) - .from(userRole) - .groupBy(userRole.roleId); + .select({ roleId: membershipRoleId, members: countDistinct(membershipUserId) }) + .from(effectiveRoleAssignments) + .groupBy(membershipRoleId); const collect = (rows: T[], keyOf: (row: T) => string, valueOf: (row: T) => string) => { const grouped = new Map(); @@ -453,10 +478,16 @@ export async function listRoleMembers( image: user.image, assignedAt: userRole.assignedAt, assignedBy: userRole.assignedBy, + direct: sql`bool_or(${membershipSourceRoleId} = ${membershipRoleId})`, + inheritedFrom: sql`coalesce(array_agg(distinct ${role.key}) filter + (where ${membershipSourceRoleId} <> ${membershipRoleId}), array[]::text[])`, }) - .from(userRole) - .innerJoin(user, eq(user.id, userRole.userId)) - .where(and(eq(userRole.roleId, roleId), after ? gt(user.id, after) : undefined)) + .from(effectiveRoleAssignments) + .innerJoin(user, eq(user.id, membershipUserId)) + .innerJoin(role, eq(role.id, membershipSourceRoleId)) + .leftJoin(userRole, and(eq(userRole.userId, user.id), eq(userRole.roleId, roleId))) + .where(and(eq(membershipRoleId, roleId), after ? gt(user.id, after) : undefined)) + .groupBy(user.id, userRole.assignedAt, userRole.assignedBy) .orderBy(user.id) .limit(101); const members = rows.slice(0, 100); @@ -529,7 +560,7 @@ export async function searchUsers( email: user.email, image: user.image, holdsRole: roleId - ? sql`exists (select 1 from ${userRole} where ${userRole.userId} = ${user.id} and ${userRole.roleId} = ${roleId})` + ? sql`exists (select 1 from ${effectiveRoleAssignments} where ${membershipUserId} = ${user.id} and ${membershipRoleId} = ${roleId})` : sql`false`, }) .from(user) diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts index ab8d0ba..5288de1 100644 --- a/src/auth/rbac.ts +++ b/src/auth/rbac.ts @@ -212,7 +212,7 @@ export type RoleSummary = { deniedPermissions?: string[]; /** Keys of the roles this role inherits permissions from. */ parents: string[]; - /** People holding a hand-made assignment. Always 0 for managed roles. */ + /** Distinct people with a hand-made assignment, directly or through descendant roles. */ memberCount: number; createdAt: string | null; updatedAt: string | null; @@ -599,6 +599,10 @@ export function hasDraftErrors(errors: RbacDraftErrors) { } export type RoleMember = { + /** Whether membership can be removed from this role itself. */ + direct?: boolean; + /** Keys of directly assigned descendant roles that confer this membership. */ + inheritedFrom?: string[]; userId: string; name: string; /** Null when no real address is known, only a placeholder. */ diff --git a/src/components/rbac/role-members.tsx b/src/components/rbac/role-members.tsx index 3498664..b6f4ba1 100644 --- a/src/components/rbac/role-members.tsx +++ b/src/components/rbac/role-members.tsx @@ -96,11 +96,18 @@ export function RoleMembers({ // they may just have changed. The button stays busy until the list shows the change, // so the person is never missing from both the search and the list. await router.invalidate({ sync: true }); - // Marked in place rather than searched again, so someone just taken off the role - // becomes assignable again without retyping or a flash of "Searching…". + // Keep search results in sync without retyping. Removing a direct assignment + // leaves inherited holders marked as members. setResults((found) => found.map((person) => - person.id === userId ? { ...person, holdsRole: action === "assign" } : person, + person.id === userId + ? { + ...person, + holdsRole: + action === "assign" || + !!members.find((member) => member.userId === userId)?.inheritedFrom?.length, + } + : person, ), ); if (action === "assign") setQuery(""); @@ -177,8 +184,17 @@ export function RoleMembers({
    {members.map((member) => (
  • - - {canWrite && ( +
    + + {!!member.inheritedFrom?.length && ( +

    + {member.direct ? "Also inherited from" : "Inherited from"}{" "} + {member.inheritedFrom.join(", ")}. Change membership on the source role to + remove it. +

    + )} +
    + {canWrite && member.direct !== false && ( )}
  • diff --git a/src/routes/access/roles/$roleId.tsx b/src/routes/access/roles/$roleId.tsx index e8509e2..616ff34 100644 --- a/src/routes/access/roles/$roleId.tsx +++ b/src/routes/access/roles/$roleId.tsx @@ -171,7 +171,7 @@ function RoleDetail() { {inferred ? inferred.evidence : canAssign - ? "Anyone you add here holds this role until you remove them." + ? "Includes direct assignments and people who inherit this role. Inherited membership is managed on its source role." : "The people currently holding this role."}