diff --git a/README.md b/README.md index 54edaeb..18e9335 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,19 @@ 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. + +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/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..c9e7675 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"])); @@ -461,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 a899e84..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,13 +56,42 @@ 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 { 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 @@ -74,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(); @@ -90,7 +119,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 +140,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 +152,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 +220,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 +233,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 +294,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 +418,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 +429,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 +440,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 @@ -433,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); @@ -509,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.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..5288de1 100644 --- a/src/auth/rbac.ts +++ b/src/auth/rbac.ts @@ -208,9 +208,11 @@ 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. */ + /** Distinct people with a hand-made assignment, directly or through descendant roles. */ memberCount: number; createdAt: string | null; updatedAt: string | null; @@ -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 @@ -515,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/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/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/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] }), 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."}