From cc47189e9fef6d7bde992c576107bf33780c70a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Sun, 25 Oct 2026 11:30:50 +0000 Subject: [PATCH 1/6] feat(rbac): let People manage bound roles through the backend Master Admin can bind a hand-made role to the admin dashboard's People register (admin, team::member, team::lead) on its page. Roles carrying idp:* permissions can't be bound, and role or permission saves that would give a bound role one are refused. - GET /api/internal/people-roles (backend client, idp:access:read) lists the bound roles and their holders with name, directory email and Telegram, including roles that grant no permission. - POST /api/internal/people-roles (backend client, new scope idp:people:roles) gives or takes back a bound role on behalf of the dashboard user the backend authenticated. This IdP resolves the actor's own access: no self-grants, people:write (or leading the team) for admin and member roles, people:appoint for Leads, admin:access, and bounded delegation unless Master Admin. Audited with via: "people". Co-Authored-By: Claude Opus 5.5 --- README.md | 6 + drizzle/0023_role_people_binding.sql | 2 + drizzle/meta/0023_snapshot.json | 2486 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/auth/access-snapshot-token.ts | 11 +- src/auth/access-snapshot.test.ts | 1 + src/auth/index.ts | 12 +- src/auth/mcp-diagnostics.test.ts | 1 + src/auth/oidc-clients.ts | 14 +- src/auth/people-binding.test.ts | 163 ++ src/auth/people-binding.ts | 109 + src/auth/people-roles.ts | 183 ++ src/auth/rbac-store.ts | 54 +- src/auth/rbac.functions.ts | 11 + src/auth/rbac.test.ts | 1 + src/auth/rbac.ts | 2 + src/components/rbac/delegation.test.ts | 1 + src/components/rbac/people-binding-card.tsx | 136 + src/components/rbac/role-form.tsx | 1 + src/db/rbac.ts | 16 +- src/routeTree.gen.ts | 21 + src/routes/access/roles/$roleId.tsx | 11 + src/routes/api/internal/people-roles.ts | 67 + 23 files changed, 3307 insertions(+), 9 deletions(-) create mode 100644 drizzle/0023_role_people_binding.sql create mode 100644 drizzle/meta/0023_snapshot.json create mode 100644 src/auth/people-binding.test.ts create mode 100644 src/auth/people-binding.ts create mode 100644 src/auth/people-roles.ts create mode 100644 src/components/rbac/people-binding-card.tsx create mode 100644 src/routes/api/internal/people-roles.ts diff --git a/README.md b/README.md index 5df1e5f..05966a5 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,12 @@ During a sign-in the login page names the requesting application. The consent pa Future consumers should use authorization code with PKCE, validate token signatures, issuer, audience, and expiration, and request `polinetwork:identity` only when needed. APIs must check their own permissions. An ID token or a linked Telegram ID alone is not permission to moderate a group. +### People roles + +Master Admin can bind a hand-made role to the admin dashboard's People register on the role's page (People register card): its holders are admins of the Telegram groups (`admin`), or members or the Lead of a team (`team::member`, `team::lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. + +`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles and everyone holding them by hand, with their name, directory email, Telegram ID and username; unlike the access snapshot it includes roles that grant no permission. `POST /api/internal/people-roles` (backend client, `idp:people:roles`) gives or takes back a bound role for `{ actor, sub, role, action: "assign" | "unassign", reason }`, on behalf of the dashboard user the backend authenticated. The backend only names the actor; this IdP resolves the actor's own access and refuses unless: the role is bound; the actor isn't giving themselves a role (giving one up is allowed); admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`; and, unless Master Admin, the actor holds every permission the role grants. Changes are audited as `role.assign`/`role.unassign` with `via: "people"` and the reason. Grant `idp:people:roles` to the backend client on its application page to enable writes. + ## MCP server `/api/mcp` is an MCP server, so an AI agent such as Claude Code or Codex can read and change this identity provider for you: debug an application that cannot sign people in, register or edit applications, and set up roles, permissions and who holds them. It only works for people holding `idp:mcp`, and the agent can only do what that person could do on the website. diff --git a/drizzle/0023_role_people_binding.sql b/drizzle/0023_role_people_binding.sql new file mode 100644 index 0000000..7a9c79d --- /dev/null +++ b/drizzle/0023_role_people_binding.sql @@ -0,0 +1,2 @@ +ALTER TABLE "role" ADD COLUMN "people_binding" text;--> statement-breakpoint +ALTER TABLE "role" ADD CONSTRAINT "role_people_binding_check" CHECK ("role"."people_binding" is null or (not "role"."managed" and "role"."people_binding" ~ '^(admin|team:[a-z0-9-]{1,32}:(member|lead))$')); \ No newline at end of file diff --git a/drizzle/meta/0023_snapshot.json b/drizzle/meta/0023_snapshot.json new file mode 100644 index 0000000..5118532 --- /dev/null +++ b/drizzle/meta/0023_snapshot.json @@ -0,0 +1,2486 @@ +{ + "id": "6e6dc4d1-22d0-44ab-a009-fd1f763786a3", + "prevId": "7795fbb9-3c55-41e8-8e86-8bfa10bf099e", + "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 + }, + "people_binding": { + "name": "people_binding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_key_uidx": { + "name": "role_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "role_people_binding_check": { + "name": "role_people_binding_check", + "value": "\"role\".\"people_binding\" is null or (not \"role\".\"managed\" and \"role\".\"people_binding\" ~ '^(admin|team:[a-z0-9-]{1,32}:(member|lead))$')" + } + }, + "isRLSEnabled": false + }, + "public.role_parent": { + "name": "role_parent", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_role_id": { + "name": "parent_role_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "roleParent_parent_idx": { + "name": "roleParent_parent_idx", + "columns": [ + { + "expression": "parent_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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 + } + }, + "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 364048d..9dc337d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1792827000000, "tag": "0022_idp_mcp", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1792927355527, + "tag": "0023_role_people_binding", + "breakpoints": true } ] } diff --git a/src/auth/access-snapshot-token.ts b/src/auth/access-snapshot-token.ts index f3bdf8f..ea7bdb5 100644 --- a/src/auth/access-snapshot-token.ts +++ b/src/auth/access-snapshot-token.ts @@ -1,9 +1,18 @@ import { createLocalJWKSet, jwtVerify, type JWK } from "jose"; import { auth } from "./index"; import { env } from "../env"; +import type { InternalScope } from "./oidc-clients"; /** Verify the IdP's own resource-bound service token without trusting its claims before signature checks. */ export async function snapshotClientId(authorization: string | null): Promise { + return serviceClientId(authorization, "idp:access:read"); +} + +/** The client a valid service token for this IdP's internal resource belongs to, when it carries `scope`. */ +export async function serviceClientId( + authorization: string | null, + scope: InternalScope, +): Promise { const bearer = /^Bearer ([A-Za-z0-9._~-]+)$/.exec(authorization ?? ""); const audience = env.OAUTH_INTERNAL_RESOURCE_URI; if (!bearer || !audience) return null; @@ -27,7 +36,7 @@ export async function snapshotClientId(authorization: string | null): Promise["id"]; +export const internalScopes = apiScopes + .filter((scope) => scope.resource === "internal") + .map((scope) => scope.id); + export type OidcResourceKey = (typeof apiScopes)[number]["resource"]; export const oidcResources: { id: OidcResourceKey; label: string; description: string }[] = [ @@ -141,7 +153,7 @@ export const oidcResources: { id: OidcResourceKey; label: string; description: s /** Prefix match on purpose: a backend scope added later is privileged before it is listed. */ export function isApiScope(scope: string) { - return scope.startsWith("backend:") || scope === "idp:access:read"; + return scope.startsWith("backend:") || (internalScopes as string[]).includes(scope); } /** The resources the selected API scopes need, in a stable order. */ diff --git a/src/auth/people-binding.test.ts b/src/auth/people-binding.test.ts new file mode 100644 index 0000000..a54e351 --- /dev/null +++ b/src/auth/people-binding.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + type PeopleRoleChange, + parsePeopleBinding, + peopleBindingProblem, + peopleRoleChangeProblem, +} from "./people-binding"; +import { + type PermissionSummary, + type RbacCatalog, + type RoleSummary, + MASTER_ADMIN_ROLE_KEY, + resolveAccess, +} from "./rbac"; + +function permission(key: string, managed = false): PermissionSummary { + return { + id: `p-${key}`, + key, + name: key, + description: null, + managed, + implies: [], + roleCount: 0, + createdAt: null, + updatedAt: null, + }; +} + +function role( + key: string, + permissions: string[], + peopleBinding: string | null = null, + parents: string[] = [], +): RoleSummary { + return { + id: `r-${key}`, + key, + name: key, + description: null, + managed: key === MASTER_ADMIN_ROLE_KEY, + sourceState: null, + permissions, + parents, + memberCount: 0, + peopleBinding, + createdAt: null, + updatedAt: null, + }; +} + +const catalog: RbacCatalog = { + permissions: [ + "admin:access", + "people:self", + "people:read", + "people:write", + "people:appoint", + "tg:trusted", + ] + .map((key) => permission(key)) + .concat(permission("idp:roles:write", true)), + roles: [ + role(MASTER_ADMIN_ROLE_KEY, []), + role("direttivo", [ + "admin:access", + "people:read", + "people:write", + "people:appoint", + "people:self", + ]), + role( + "hr-member", + ["admin:access", "people:read", "people:write", "people:self"], + "team:hr:member", + ), + role("hr-lead", ["people:self"], "team:hr:lead", ["hr-member"]), + role("design-member", ["people:self"], "team:design:member"), + role("design-lead", ["people:self"], "team:design:lead"), + role("admin", ["tg:trusted"], "admin"), + role("sneaky", ["idp:roles:write"], "team:it:member"), + role("unbound", ["people:self"]), + ], +}; + +const byKey = (key: string) => catalog.roles.find((entry) => entry.key === key)!; + +function change( + actorRoles: string[], + roleKey: string, + overrides: Partial = {}, +): string | null { + return peopleRoleChangeProblem({ + catalog, + actorId: "actor", + actor: resolveAccess(catalog, actorRoles), + targetId: "target", + role: byKey(roleKey), + action: "assign", + ...overrides, + }); +} + +describe("parsePeopleBinding", () => { + it("reads admin and team bindings", () => { + expect(parsePeopleBinding("admin")).toEqual({ kind: "admin" }); + expect(parsePeopleBinding("team:events:lead")).toEqual({ + kind: "team", + team: "events", + role: "lead", + }); + }); + + it("rejects anything else", () => { + for (const value of [null, "", "team:it", "team:IT:member", "team:it:owner", "admins"]) + expect(parsePeopleBinding(value)).toBeNull(); + }); +}); + +describe("peopleBindingProblem", () => { + it("refuses roles that carry identity provider permissions", () => { + expect(peopleBindingProblem(catalog, byKey("sneaky"))).toMatch(/identity provider/); + expect(peopleBindingProblem(catalog, byKey("design-member"))).toBeNull(); + }); +}); + +describe("peopleRoleChangeProblem", () => { + it("refuses roles People doesn't manage", () => { + expect(change(["direttivo"], "unbound")).toMatch(/not managed/); + expect(change([MASTER_ADMIN_ROLE_KEY], "sneaky")).toMatch(/identity provider/); + }); + + it("never lets someone give themselves a role, but lets them give one up", () => { + expect(change(["direttivo"], "design-member", { targetId: "actor" })).toMatch(/themselves/); + expect(change([MASTER_ADMIN_ROLE_KEY], "admin", { targetId: "actor" })).toMatch(/themselves/); + expect(change([], "design-lead", { targetId: "actor", action: "unassign" })).toBeNull(); + }); + + it("needs people:write for members and admins, people:appoint for Leads", () => { + expect(change(["hr-member"], "design-member")).toBeNull(); + expect(change(["hr-member"], "design-lead")).toMatch(/people:appoint/); + expect(change(["direttivo"], "design-lead")).toBeNull(); + expect(change(["design-member"], "design-member")).toMatch(/people:write/); + }); + + it("lets a Lead change their own team's members only", () => { + expect(change(["design-lead"], "design-member")).toBeNull(); + expect(change(["design-lead"], "design-member", { action: "unassign" })).toBeNull(); + expect(change(["design-lead"], "hr-member")).toMatch(/people:write/); + expect(change(["design-lead"], "design-lead")).toMatch(/people:appoint/); + }); + + it("keeps an HR member from making anyone the HR Lead", () => { + expect(change(["hr-member"], "hr-lead")).toMatch(/people:appoint/); + }); + + it("refuses roles granting permissions the actor lacks, except to Master Admin", () => { + expect(change(["hr-member"], "admin")).toMatch(/tg:trusted/); + expect(change(["direttivo"], "admin", { action: "unassign" })).toMatch(/tg:trusted/); + expect(change(["direttivo", "admin"], "admin")).toBeNull(); + expect(change([MASTER_ADMIN_ROLE_KEY], "admin")).toBeNull(); + }); +}); diff --git a/src/auth/people-binding.ts b/src/auth/people-binding.ts new file mode 100644 index 0000000..cffb57b --- /dev/null +++ b/src/auth/people-binding.ts @@ -0,0 +1,109 @@ +// Shared by the server and the browser: keep this file free of server-only imports. +import { + type RbacCatalog, + type ResolvedAccess, + type RoleSummary, + MASTER_ADMIN_ROLE_KEY, + effectiveRolePermissions, + isManagedPermissionKey, +} from "./rbac"; + +/** + * What holding a role means in the People register of the admin dashboard: + * - `admin`: an admin of the Telegram course groups; + * - `team::member` / `team::lead`: a member or the Lead of a volunteer team. + * + * Only a bound role can be handed out or taken back by the backend on someone's behalf, and + * only Master Admin binds a role, because binding one delegates its membership to People. + */ +export type PeopleBinding = + | { kind: "admin" } + | { kind: "team"; team: string; role: "member" | "lead" }; + +/** Mirrors the `role_people_binding_check` constraint. */ +export const PEOPLE_BINDING_PATTERN = /^(admin|team:[a-z0-9-]{1,32}:(member|lead))$/; + +export function parsePeopleBinding(value: string | null | undefined): PeopleBinding | null { + if (!value || !PEOPLE_BINDING_PATTERN.test(value)) return null; + if (value === "admin") return { kind: "admin" }; + const [, team, role] = value.split(":"); + return { kind: "team", team: team!, role: role as "member" | "lead" }; +} + +export function describePeopleBinding(binding: PeopleBinding) { + if (binding.kind === "admin") return "Admin of the Telegram groups"; + return `${binding.role === "lead" ? "Lead" : "Member"} of team ${binding.team}`; +} + +/** + * Why a role can't be bound, or null when it can. A bound role must stay below this identity + * provider's own permissions: People writers must never be able to hand out `idp:*` rights. + */ +export function peopleBindingProblem(catalog: RbacCatalog, role: RoleSummary): string | null { + if (role.managed) return "Roles defined by the identity provider cannot be managed from People."; + if (effectiveRolePermissions(catalog, role.key).some(isManagedPermissionKey)) + return "This role grants permissions of the identity provider itself, so People can't manage it."; + return null; +} + +/** The permissions that let a dashboard user change who holds a bound role, besides `admin:access`. */ +export const PEOPLE_WRITE_PERMISSION = "people:write"; +export const PEOPLE_APPOINT_PERMISSION = "people:appoint"; +export const ADMIN_ACCESS_PERMISSION = "admin:access"; + +export type PeopleRoleChange = { + catalog: RbacCatalog; + actorId: string; + /** Everything the actor holds, from assignments and identity evidence. */ + actor: ResolvedAccess; + targetId: string; + role: RoleSummary; + action: "assign" | "unassign"; +}; + +/** + * Whether the backend may change who holds `role` on the actor's behalf; a reason when not. + * + * - Only bound roles, and only while they carry no `idp:*` permission. + * - Nobody gives themselves a role. Giving one up is always allowed: it only removes access. + * - Admin and team member roles need `people:write`; a team's Lead may also add and remove + * that team's members. Lead roles need `people:appoint`. Both need `admin:access`, except + * for a Lead acting on their own team. + * - Bounded delegation, as in the dashboard: unless Master Admin, the actor must already hold + * every permission the role grants, so nobody hands out more than they have. + */ +export function peopleRoleChangeProblem(change: PeopleRoleChange): string | null { + const { catalog, actorId, actor, targetId, role, action } = change; + const binding = parsePeopleBinding(role.peopleBinding); + if (!binding) return "This role is not managed from People."; + const problem = peopleBindingProblem(catalog, role); + if (problem) return problem; + if (actorId === targetId) + return action === "unassign" ? null : "Nobody can give themselves a role."; + + const holds = (key: string) => actor.permissions.includes(key); + const general = (key: string) => holds(ADMIN_ACCESS_PERMISSION) && holds(key); + const leadsTeam = (team: string) => + actor.roles.some((key) => { + const held = parsePeopleBinding( + catalog.roles.find((entry) => entry.key === key)?.peopleBinding, + ); + return held?.kind === "team" && held.team === team && held.role === "lead"; + }); + const allowed = + binding.kind === "admin" + ? general(PEOPLE_WRITE_PERMISSION) + : binding.role === "lead" + ? general(PEOPLE_APPOINT_PERMISSION) + : general(PEOPLE_WRITE_PERMISSION) || leadsTeam(binding.team); + if (!allowed) + return binding.kind === "team" && binding.role === "lead" + ? "Appointing or ending a Lead needs people:appoint and admin:access." + : "Changing this role needs people:write and admin:access, or leading the team."; + + if (actor.roles.includes(MASTER_ADMIN_ROLE_KEY)) return null; + const missing = effectiveRolePermissions(catalog, role.key).filter((key) => !holds(key)); + return missing.length + ? `This role grants permissions you do not hold (${missing.join(", ")}).` + : null; +} diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts new file mode 100644 index 0000000..6c15485 --- /dev/null +++ b/src/auth/people-roles.ts @@ -0,0 +1,183 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; +import { db } from "../db/index"; +import { authorizationMutationLock } from "../db/security-lock"; +import { account, identityEvidence, rbacAuditEvent, role, user, userRole } from "../db/schema"; +import { contactEmails } from "./contact-email"; +import { logAuthorizationDenial } from "./denial-log"; +import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; +import { + parsePeopleBinding, + peopleBindingProblem, + peopleRoleChangeProblem, +} from "./people-binding"; +import { expandRoleKeys, resolveAccess } from "./rbac"; +import { RbacError, assignedRoleKeys, auditSnapshot, readCatalog } from "./rbac-store"; + +export type PeopleDirectory = { + /** Every role People manages, with what holding it means. */ + roles: { key: string; name: string; binding: string }[]; + /** Everyone holding at least one of those roles, by hand or through a parent role. */ + users: { + sub: string; + name: string; + /** Their PoliNetwork address when they linked it, otherwise their sign-in address. */ + email: string | null; + telegramId: string | null; + telegramUsername: string | null; + /** The bound roles they hold, sorted. */ + roles: string[]; + }[]; +}; + +/** + * Who holds the roles People manages, for the backend's People register. Unlike the access + * snapshot it includes roles that grant no permission, such as a plain team membership. + * Roles conferred by identity evidence are never bound, so only assignments count. + */ +export async function readPeopleDirectory(): Promise { + return db.transaction( + async (transaction) => { + const catalog = await readCatalog(transaction); + const bound = catalog.roles.filter( + (entry) => parsePeopleBinding(entry.peopleBinding) && !peopleBindingProblem(catalog, entry), + ); + const boundKeys = new Set(bound.map((entry) => entry.key)); + const roles = bound.map((entry) => ({ + key: entry.key, + name: entry.name, + binding: entry.peopleBinding!, + })); + if (!bound.length) return { roles, users: [] }; + + const assignments = await transaction + .select({ userId: userRole.userId, roleKey: role.key }) + .from(userRole) + .innerJoin(role, eq(userRole.roleId, role.id)) + .where(eq(role.managed, false)); + const assigned = new Map(); + for (const row of assignments) + assigned.set(row.userId, [...(assigned.get(row.userId) ?? []), row.roleKey]); + const held = new Map(); + for (const [userId, keys] of assigned) { + const roles = expandRoleKeys(catalog, keys).filter((key) => boundKeys.has(key)); + if (roles.length) held.set(userId, roles); + } + const ids = [...held.keys()]; + if (!ids.length) return { roles, users: [] }; + + const people = await transaction + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .where(inArray(user.id, ids)) + .orderBy(user.id); + const emails = await contactEmails(transaction, people); + const telegram = await transaction + .select({ + userId: account.userId, + telegramId: identityEvidence.telegramId, + telegramUsername: identityEvidence.telegramUsername, + }) + .from(account) + .innerJoin( + identityEvidence, + and( + eq(account.issuer, identityEvidence.issuer), + eq(account.accountId, identityEvidence.subject), + eq(account.providerId, identityEvidence.providerId), + ), + ) + .where(and(inArray(account.userId, ids), eq(account.providerId, "telegram"))); + const telegramByUser = new Map(telegram.map((row) => [row.userId, row])); + + return { + roles, + users: people.map((person) => ({ + sub: person.id, + name: person.name, + email: emails.get(person.id) ?? null, + telegramId: telegramByUser.get(person.id)?.telegramId ?? null, + telegramUsername: telegramByUser.get(person.id)?.telegramUsername ?? null, + roles: held.get(person.id)!, + })), + }; + }, + { isolationLevel: "repeatable read", accessMode: "read only" }, + ); +} + +export type PeopleRoleChangeInput = { + /** The dashboard user the backend acts for, as authenticated by the backend. */ + actorId: string; + targetId: string; + roleKey: string; + action: "assign" | "unassign"; + reason: string; +}; + +/** + * Gives or takes back a role People manages, on behalf of a dashboard user. The backend is + * trusted to name the actor it authenticated, never to decide what that actor may do: the + * actor's own access is resolved here and checked by `peopleRoleChangeProblem`. + */ +export async function changePeopleRole(input: PeopleRoleChangeInput): Promise { + await refreshIdentityMembership(input.actorId); + await db.transaction( + async (transaction) => { + await transaction.execute(authorizationMutationLock); + const catalog = await readCatalog(transaction); + const target = catalog.roles.find((entry) => entry.key === input.roleKey); + if (!target) throw new RbacError(404, "Role not found."); + const [actor] = await transaction + .select({ id: user.id }) + .from(user) + .where(eq(user.id, input.actorId)); + if (!actor) throw new RbacError(403, "The acting person has no account here."); + const [person] = await transaction + .select({ id: user.id }) + .from(user) + .where(eq(user.id, input.targetId)); + if (!person) throw new RbacError(404, "That person was not found."); + + const subject = await readIdentitySubject(input.actorId, transaction); + const access = resolveAccess(catalog, [ + ...(await assignedRoleKeys(input.actorId, catalog, transaction)), + ...subject.roleKeys, + ]); + const problem = peopleRoleChangeProblem({ + catalog, + actorId: input.actorId, + actor: access, + targetId: input.targetId, + role: target, + action: input.action, + }); + if (problem) { + logAuthorizationDenial(input.actorId, "people-roles", [`${input.action}:${target.key}`]); + throw new RbacError(403, problem); + } + + const operation = input.action === "assign" ? "role.assign" : "role.unassign"; + const before = await auditSnapshot(transaction, catalog, operation, target.id); + if (input.action === "assign") + await transaction + .insert(userRole) + .values({ roleId: target.id, userId: input.targetId, assignedBy: input.actorId }) + .onConflictDoNothing(); + else + await transaction + .delete(userRole) + .where(and(eq(userRole.roleId, target.id), eq(userRole.userId, input.targetId))); + const after = await auditSnapshot(transaction, catalog, operation, target.id); + await transaction.insert(rbacAuditEvent).values({ + id: randomUUID(), + actorId: input.actorId, + operation, + targetId: target.id, + before, + after: { ...after, via: "people", userId: input.targetId, reason: input.reason }, + }); + }, + { isolationLevel: "read committed" }, + ); +} diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts index 76705b7..37dd56e 100644 --- a/src/auth/rbac-store.ts +++ b/src/auth/rbac-store.ts @@ -2,6 +2,7 @@ import { groupListings, traitConditions, searchCondition } from "./people-search import type { FieldErrors } from "@/lib/action-error"; import { contactEmails } from "./contact-email"; import { mayDelegateMutation } from "./rbac-delegation"; +import { PEOPLE_BINDING_PATTERN, peopleBindingProblem } from "./people-binding"; import { logAuthorizationDenial } from "./denial-log"; import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; import type { IdentityClaims } from "./policy"; @@ -119,6 +120,7 @@ export async function readCatalog(db: CatalogReader): Promise { permissions: permissionsByRole.get(row.id) ?? [], parents: parentsByRole.get(row.id) ?? [], memberCount: memberCounts.get(row.id) ?? 0, + peopleBinding: row.peopleBinding, createdAt: row.createdAt?.toISOString() ?? null, updatedAt: row.updatedAt?.toISOString() ?? null, })), @@ -206,7 +208,7 @@ export async function withAuthorizedRbacWrite( ); } -async function auditSnapshot( +export async function auditSnapshot( transaction: Transaction, catalog: RbacCatalog, operation: string, @@ -300,6 +302,17 @@ function idsForRoleKeys(catalog: RbacCatalog, keys: string[]) { }); } +/** + * A role People manages must never come to carry this identity provider's own rights, directly, + * through a parent or through an implication: People writers could then hand them out. + */ +function assertPeopleBindings(catalog: RbacCatalog) { + for (const entry of catalog.roles) { + const problem = entry.peopleBinding ? peopleBindingProblem(catalog, entry) : null; + if (problem) throw new RbacError(400, `${entry.name} is managed from People. ${problem}`); + } +} + function checked(errors: Record) { if (hasDraftErrors(errors)) throw new RbacError(400, "Check the highlighted fields.", errors); } @@ -338,7 +351,9 @@ export async function savePermission( .values( impliedIds.map((impliedPermissionId) => ({ permissionId: id, impliedPermissionId })), ); - return (await readCatalog(transaction)).permissions.find((entry) => entry.id === id)!; + const next = await readCatalog(transaction); + assertPeopleBindings(next); + return next.permissions.find((entry) => entry.id === id)!; }); } @@ -404,7 +419,40 @@ export async function saveRole( await transaction .insert(roleParent) .values(parentIds.map((parentRoleId) => ({ roleId: id, parentRoleId }))); - return (await readCatalog(transaction)).roles.find((entry) => entry.id === id)!; + const next = await readCatalog(transaction); + assertPeopleBindings(next); + return next.roles.find((entry) => entry.id === id)!; + }); +} + +/** + * Binds a role to a People concept, or unbinds it with `null`. Binding hands the role's + * membership to People writers (through the backend), so only Master Admin decides it. + */ +export async function setRolePeopleBinding( + actorId: string, + roleId: string, + binding: string | null, +) { + return withRbacWriteLock(actorId, "role.people", roleId, async (transaction, catalog, access) => { + if (!access.roles.includes(MASTER_ADMIN_ROLE_KEY)) { + logAuthorizationDenial(actorId, "rbac-store", [MASTER_ADMIN_ROLE_KEY]); + throw new RbacError(403, "Only Master Admin can choose which roles People manages."); + } + const current = requireRole(catalog, roleId); + if (binding !== null) { + if (!PEOPLE_BINDING_PATTERN.test(binding)) + throw new RbacError(400, "Choose admin, or a team key with member or lead.", { + peopleBinding: "Use admin, team::member or team::lead.", + }); + const problem = peopleBindingProblem(catalog, current); + if (problem) throw new RbacError(400, problem); + } + await transaction + .update(role) + .set({ peopleBinding: binding, updatedAt: new Date() }) + .where(eq(role.id, roleId)); + return (await readCatalog(transaction)).roles.find((entry) => entry.id === roleId)!; }); } diff --git a/src/auth/rbac.functions.ts b/src/auth/rbac.functions.ts index e35d9fb..6678f28 100644 --- a/src/auth/rbac.functions.ts +++ b/src/auth/rbac.functions.ts @@ -11,6 +11,7 @@ import { savePermission, saveRole, searchUsers, + setRolePeopleBinding, unassignRole, } from "./rbac-store"; @@ -33,6 +34,16 @@ export const saveRoleFn = createServerFn({ method: "POST" }) .validator(validate(z.object({ roleId: z.string().min(1).optional(), draft: roleDraftSchema }))) .handler(({ context, data }) => saveRole(context.session.userId, data.draft, data.roleId)); +/** Binds a role to People, or unbinds it with `null`. The store allows Master Admin only. */ +export const setRolePeopleBindingFn = createServerFn({ method: "POST" }) + .middleware([permissionMiddleware("idp:roles:write")]) + .validator( + validate(z.object({ roleId: z.string().min(1), binding: z.string().max(64).nullable() })), + ) + .handler(({ context, data }) => + setRolePeopleBinding(context.session.userId, data.roleId, data.binding), + ); + export const deleteRoleFn = createServerFn({ method: "POST" }) .middleware([permissionMiddleware("idp:roles:write")]) .validator(validate(z.object({ roleId: z.string().min(1) }))) diff --git a/src/auth/rbac.test.ts b/src/auth/rbac.test.ts index baa4a16..597856a 100644 --- a/src/auth/rbac.test.ts +++ b/src/auth/rbac.test.ts @@ -50,6 +50,7 @@ function role( permissions, parents, memberCount: 0, + peopleBinding: null, createdAt: null, updatedAt: null, }; diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts index 2b56356..337d8f1 100644 --- a/src/auth/rbac.ts +++ b/src/auth/rbac.ts @@ -219,6 +219,8 @@ export type RoleSummary = { parents: string[]; /** People holding a hand-made assignment. Always 0 for managed roles. */ memberCount: number; + /** What holding it means in the People register; see `people-binding.ts`. */ + peopleBinding: string | null; createdAt: string | null; updatedAt: string | null; }; diff --git a/src/components/rbac/delegation.test.ts b/src/components/rbac/delegation.test.ts index 783a0a1..f3f02bf 100644 --- a/src/components/rbac/delegation.test.ts +++ b/src/components/rbac/delegation.test.ts @@ -24,6 +24,7 @@ const catalog: RbacCatalog = { permissions: key === "writer" ? ["write"] : [], parents: key === "writer" ? [] : ["writer"], memberCount: 0, + peopleBinding: null, createdAt: null, updatedAt: null, })), diff --git a/src/components/rbac/people-binding-card.tsx b/src/components/rbac/people-binding-card.tsx new file mode 100644 index 0000000..9b19809 --- /dev/null +++ b/src/components/rbac/people-binding-card.tsx @@ -0,0 +1,136 @@ +import { useRouter } from "@tanstack/react-router"; +import { useState } from "react"; +import { toast } from "sonner"; +import { parsePeopleBinding } from "@/auth/people-binding"; +import type { RoleSummary } from "@/auth/rbac"; +import { setRolePeopleBindingFn } from "@/auth/rbac.functions"; +import { Field } from "@/components/rbac/fields"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { errorMessage } from "@/lib/action-error"; + +type Kind = "none" | "admin" | "member" | "lead"; + +const KINDS: { value: Kind; label: string }[] = [ + { value: "none", label: "Not managed from People" }, + { value: "admin", label: "Admin of the Telegram groups" }, + { value: "member", label: "Team member" }, + { value: "lead", label: "Team Lead" }, +]; + +function bindingOf(kind: Kind, team: string) { + if (kind === "none") return null; + if (kind === "admin") return "admin"; + return `team:${team.trim().toLowerCase()}:${kind}`; +} + +/** + * What holding this role means in the admin dashboard's People register. A bound role is + * listed there and can be given or taken back from People by whoever may change it. + */ +export function PeopleBindingCard({ + role, + canEdit, + problem, +}: { + role: RoleSummary; + canEdit: boolean; + /** Why the role can't be bound, if it can't. */ + problem: string | null; +}) { + const router = useRouter(); + const current = parsePeopleBinding(role.peopleBinding); + const [kind, setKind] = useState( + current?.kind === "team" ? current.role : (current?.kind ?? "none"), + ); + const [team, setTeam] = useState(current?.kind === "team" ? current.team : ""); + const [busy, setBusy] = useState(false); + const next = bindingOf(kind, team); + const teamMissing = + (kind === "member" || kind === "lead") && !/^[a-z0-9-]{1,32}$/.test(team.trim().toLowerCase()); + + async function save() { + setBusy(true); + try { + await setRolePeopleBindingFn({ data: { roleId: role.id, binding: next } }); + await router.invalidate({ sync: true }); + toast.success(next ? "People now manages this role." : "People no longer manages this role."); + } catch (cause) { + toast.error(errorMessage(cause, "Unable to save.")); + } finally { + setBusy(false); + } + } + + return ( + + + People register + + Holders of a role managed from People appear in the admin dashboard's People register. + Whoever may change that part of the register there can give or take back the role, never + to themselves, and never beyond the permissions they hold. + + + + {!canEdit ? ( +

+ {KINDS.find((entry) => entry.value === kind)?.label} + {(kind === "member" || kind === "lead") && ` of team ${team}`}. Only Master Admin can + change this. +

+ ) : problem && !current ? ( +

{problem}

+ ) : ( + <> +
+ + + + {(kind === "member" || kind === "lead") && ( + + setTeam(event.target.value)} + className="font-mono" + /> + + )} +
+ + + )} +
+
+ ); +} diff --git a/src/components/rbac/role-form.tsx b/src/components/rbac/role-form.tsx index 55a8628..67faf70 100644 --- a/src/components/rbac/role-form.tsx +++ b/src/components/rbac/role-form.tsx @@ -121,6 +121,7 @@ export function RoleForm({ permissions: draft.permissions, parents: draft.parents, memberCount: 0, + peopleBinding: null, createdAt: null, updatedAt: null, }, diff --git a/src/db/rbac.ts b/src/db/rbac.ts index 38411af..fc1190f 100644 --- a/src/db/rbac.ts +++ b/src/db/rbac.ts @@ -1,5 +1,7 @@ +import { sql } from "drizzle-orm"; import { boolean, + check, index, jsonb, pgTable, @@ -68,10 +70,22 @@ export const role = pgTable( description: text(), managed: boolean().default(false).notNull(), sourceState: text("source_state"), + /** + * What holding this role means in the People register (`admin`, `team::member` or + * `team::lead`). Only a bound role can be handed out or taken back by the backend on + * behalf of a dashboard user; see `src/auth/people-roles.ts`. + */ + peopleBinding: text("people_binding"), createdAt: now(), updatedAt: now(), }, - (table) => [uniqueIndex("role_key_uidx").on(table.key)], + (table) => [ + uniqueIndex("role_key_uidx").on(table.key), + check( + "role_people_binding_check", + sql`${table.peopleBinding} is null or (not ${table.managed} and ${table.peopleBinding} ~ '^(admin|team:[a-z0-9-]{1,32}:(member|lead))$')`, + ), + ], ); /** Role hierarchy: `role_id` inherits every permission of `parent_role_id`. */ diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 468be0b..66fe3f1 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -37,6 +37,7 @@ import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiDevLoginRouteImport } from './routes/api/dev/login' import { Route as ApiInternalAccessSnapshotRouteImport } from './routes/api/internal/access-snapshot' import { Route as ApiInternalEsignUsersRouteImport } from './routes/api/internal/esign-users' +import { Route as ApiInternalPeopleRolesRouteImport } from './routes/api/internal/people-roles' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -183,6 +184,11 @@ const ApiInternalEsignUsersRoute = ApiInternalEsignUsersRouteImport.update({ path: '/api/internal/esign-users', getParentRoute: () => rootRouteImport, } as any) +const ApiInternalPeopleRolesRoute = ApiInternalPeopleRolesRouteImport.update({ + id: '/api/internal/people-roles', + path: '/api/internal/people-roles', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -211,6 +217,7 @@ export interface FileRoutesByFullPath { '/api/dev/login': typeof ApiDevLoginRoute '/api/internal/access-snapshot': typeof ApiInternalAccessSnapshotRoute '/api/internal/esign-users': typeof ApiInternalEsignUsersRoute + '/api/internal/people-roles': typeof ApiInternalPeopleRolesRoute '/access/permissions/': typeof AccessPermissionsIndexRoute '/access/roles/': typeof AccessRolesIndexRoute } @@ -238,6 +245,7 @@ export interface FileRoutesByTo { '/api/dev/login': typeof ApiDevLoginRoute '/api/internal/access-snapshot': typeof ApiInternalAccessSnapshotRoute '/api/internal/esign-users': typeof ApiInternalEsignUsersRoute + '/api/internal/people-roles': typeof ApiInternalPeopleRolesRoute '/access/permissions': typeof AccessPermissionsIndexRoute '/access/roles': typeof AccessRolesIndexRoute } @@ -269,6 +277,7 @@ export interface FileRoutesById { '/api/dev/login': typeof ApiDevLoginRoute '/api/internal/access-snapshot': typeof ApiInternalAccessSnapshotRoute '/api/internal/esign-users': typeof ApiInternalEsignUsersRoute + '/api/internal/people-roles': typeof ApiInternalPeopleRolesRoute '/access/permissions/': typeof AccessPermissionsIndexRoute '/access/roles/': typeof AccessRolesIndexRoute } @@ -301,6 +310,7 @@ export interface FileRouteTypes { | '/api/dev/login' | '/api/internal/access-snapshot' | '/api/internal/esign-users' + | '/api/internal/people-roles' | '/access/permissions/' | '/access/roles/' fileRoutesByTo: FileRoutesByTo @@ -328,6 +338,7 @@ export interface FileRouteTypes { | '/api/dev/login' | '/api/internal/access-snapshot' | '/api/internal/esign-users' + | '/api/internal/people-roles' | '/access/permissions' | '/access/roles' id: @@ -358,6 +369,7 @@ export interface FileRouteTypes { | '/api/dev/login' | '/api/internal/access-snapshot' | '/api/internal/esign-users' + | '/api/internal/people-roles' | '/access/permissions/' | '/access/roles/' fileRoutesById: FileRoutesById @@ -379,6 +391,7 @@ export interface RootRouteChildren { ApiDevLoginRoute: typeof ApiDevLoginRoute ApiInternalAccessSnapshotRoute: typeof ApiInternalAccessSnapshotRoute ApiInternalEsignUsersRoute: typeof ApiInternalEsignUsersRoute + ApiInternalPeopleRolesRoute: typeof ApiInternalPeopleRolesRoute } declare module '@tanstack/react-router' { @@ -579,6 +592,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiInternalEsignUsersRouteImport parentRoute: typeof rootRouteImport } + '/api/internal/people-roles': { + id: '/api/internal/people-roles' + path: '/api/internal/people-roles' + fullPath: '/api/internal/people-roles' + preLoaderRoute: typeof ApiInternalPeopleRolesRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -655,6 +675,7 @@ const rootRouteChildren: RootRouteChildren = { ApiDevLoginRoute: ApiDevLoginRoute, ApiInternalAccessSnapshotRoute: ApiInternalAccessSnapshotRoute, ApiInternalEsignUsersRoute: ApiInternalEsignUsersRoute, + ApiInternalPeopleRolesRoute: ApiInternalPeopleRolesRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/access/roles/$roleId.tsx b/src/routes/access/roles/$roleId.tsx index e8509e2..a57640b 100644 --- a/src/routes/access/roles/$roleId.tsx +++ b/src/routes/access/roles/$roleId.tsx @@ -13,7 +13,9 @@ import { import { deleteRoleFn, getCatalog, getRoleMembers, saveRoleFn } from "@/auth/rbac.functions"; import { requireAccess, useAccess } from "@/components/access"; import { ConfirmDelete } from "@/components/confirm-delete"; +import { peopleBindingProblem } from "@/auth/people-binding"; import { KeyChip } from "@/components/rbac/fields"; +import { PeopleBindingCard } from "@/components/rbac/people-binding-card"; import { RoleForm } from "@/components/rbac/role-form"; import { RoleMembers } from "@/components/rbac/role-members"; import { canGrantRole } from "@/components/rbac/delegation"; @@ -164,6 +166,15 @@ function RoleDetail() { + {!role.managed && ( + + )} + {role.managed ? "Who holds this role" : "People"} diff --git a/src/routes/api/internal/people-roles.ts b/src/routes/api/internal/people-roles.ts new file mode 100644 index 0000000..6e12384 --- /dev/null +++ b/src/routes/api/internal/people-roles.ts @@ -0,0 +1,67 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { serviceClientId } from "@/auth/access-snapshot-token"; +import { projectionForClient } from "@/auth/access-projections"; +import { changePeopleRole, readPeopleDirectory } from "@/auth/people-roles"; +import { RbacError } from "@/auth/rbac-store"; +import { env } from "@/env"; + +const headers = { "Cache-Control": "private, no-store", Vary: "Authorization" }; + +const changeSchema = z.object({ + actor: z.string().min(1).max(200), + sub: z.string().min(1).max(200), + role: z.string().min(1).max(200), + action: z.enum(["assign", "unassign"]), + reason: z.string().trim().max(500), +}); + +/** Only the backend's own client, with the scope the request needs. */ +async function backendClient(request: Request, scope: "idp:access:read" | "idp:people:roles") { + if (!env.OAUTH_BACKEND_CLIENT_ID || !env.OAUTH_INTERNAL_RESOURCE_URI) + return Response.json({ error: "Unavailable" }, { status: 503, headers }); + const clientId = await serviceClientId(request.headers.get("authorization"), scope); + if (!clientId) return Response.json({ error: "Unauthorized" }, { status: 401, headers }); + if (projectionForClient(clientId) !== "backend") + return Response.json({ error: "Forbidden" }, { status: 403, headers }); + return null; +} + +/** + * The roles Master Admin bound to People and who holds them (GET), and changes to them made + * by the backend on behalf of a dashboard user (POST). See `src/auth/people-binding.ts` for + * what the acting person needs. + */ +export const Route = createFileRoute("/api/internal/people-roles")({ + server: { + handlers: { + GET: async ({ request }) => { + const refused = await backendClient(request, "idp:access:read"); + if (refused) return refused; + return Response.json(await readPeopleDirectory(), { headers }); + }, + POST: async ({ request }) => { + const refused = await backendClient(request, "idp:people:roles"); + if (refused) return refused; + const parsed = changeSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) + return Response.json({ error: "Invalid request" }, { status: 400, headers }); + try { + await changePeopleRole({ + actorId: parsed.data.actor, + targetId: parsed.data.sub, + roleKey: parsed.data.role, + action: parsed.data.action, + reason: parsed.data.reason, + }); + return new Response(null, { status: 204, headers }); + } catch (error) { + if (error instanceof RbacError) + return Response.json({ error: error.message }, { status: error.status, headers }); + console.error(error); + return Response.json({ error: "Unavailable" }, { status: 500, headers }); + } + }, + }, + }, +}); From 8559db46644534627e2701c035ba67b40d0e7d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Sun, 25 Oct 2026 13:15:05 +0000 Subject: [PATCH 2/6] feat(mcp): bind roles to People from an agent Master Admin can call set_role_people_binding, so an agent connected to the MCP server can set up People roles end to end alongside create_permission, create_role and assign_role. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/auth/mcp-server.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 05966a5..04700ac 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Every tool calls the same code as the matching page, which checks the permission | `idp:roles:write` | `create_role`, `update_role`, `delete_role` | | `idp:roles:assign` | `assign_role`, `unassign_role` | | `idp:permissions:write` | `create_permission`, `update_permission`, `delete_permission` | -| Master Admin | `list_audit_events` | +| Master Admin | `list_audit_events`, `set_role_people_binding` | Deleting people is left to the website. Deletes ask the agent to repeat the name or key. New client secrets go back to the agent once, like on the page, so it can hand them over. diff --git a/src/auth/mcp-server.ts b/src/auth/mcp-server.ts index 7db3883..7dd4db0 100644 --- a/src/auth/mcp-server.ts +++ b/src/auth/mcp-server.ts @@ -54,6 +54,7 @@ import { savePermission, saveRole, searchUsers, + setRolePeopleBinding, unassignRole, withAuthorizedRbacRead, } from "./rbac-store"; @@ -835,6 +836,31 @@ export function createMcpServer(principal: McpPrincipal) { } if (masterAdmin) { + server.registerTool( + offer("set_role_people_binding"), + { + title: "Manage a role from People", + description: + "Binds a hand-made role to the admin dashboard's People register, or unbinds it with null. Holders of a bound role appear in People, and whoever may change that part of People can give or take it back, never to themselves and never beyond the permissions they hold. Roles carrying idp:* permissions can't be bound. Master Admin only.", + inputSchema: z.object({ + role: roleRef, + binding: z + .string() + .max(64) + .nullable() + .describe( + "admin (Telegram group admins), team::member or team::lead, with the dashboard's team key: it, hr, design, intl or events.", + ), + }), + annotations: { idempotentHint: true }, + }, + ({ role, binding }) => + respond(async () => { + const current = findRole(await loadCatalog(actor), role); + return setRolePeopleBinding(actor, current.id, binding); + }), + ); + server.registerTool( offer("list_audit_events"), { From b0448ab2045b9c2b1395e18e3c8b3ee11172dd7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Sun, 25 Oct 2026 13:25:08 +0000 Subject: [PATCH 3/6] fix(rbac): refuse unknown People actors and skip audits for no-op changes - An unknown actor got a 500 from the membership refresh; it is now the intended 403. - A People change that alters nothing (already held, or held only through a parent role) no longer writes an audit row claiming it did. - The directory says which bound roles each holder was given directly, the only ones a change can take back. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/auth/people-roles.ts | 30 ++++++++++++++++++++---------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 04700ac..ae120db 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,7 @@ Future consumers should use authorization code with PKCE, validate token signatu Master Admin can bind a hand-made role to the admin dashboard's People register on the role's page (People register card): its holders are admins of the Telegram groups (`admin`), or members or the Lead of a team (`team::member`, `team::lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. -`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles and everyone holding them by hand, with their name, directory email, Telegram ID and username; unlike the access snapshot it includes roles that grant no permission. `POST /api/internal/people-roles` (backend client, `idp:people:roles`) gives or takes back a bound role for `{ actor, sub, role, action: "assign" | "unassign", reason }`, on behalf of the dashboard user the backend authenticated. The backend only names the actor; this IdP resolves the actor's own access and refuses unless: the role is bound; the actor isn't giving themselves a role (giving one up is allowed); admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`; and, unless Master Admin, the actor holds every permission the role grants. Changes are audited as `role.assign`/`role.unassign` with `via: "people"` and the reason. Grant `idp:people:roles` to the backend client on its application page to enable writes. +`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles and everyone holding them by hand, with their name, directory email, Telegram ID and username, and which of those roles they were given directly (only those can be taken back); unlike the access snapshot it includes roles that grant no permission. `POST /api/internal/people-roles` (backend client, `idp:people:roles`) gives or takes back a bound role for `{ actor, sub, role, action: "assign" | "unassign", reason }`, on behalf of the dashboard user the backend authenticated. The backend only names the actor; this IdP resolves the actor's own access and refuses unless: the role is bound; the actor isn't giving themselves a role (giving one up is allowed); admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`; and, unless Master Admin, the actor holds every permission the role grants. Changes are audited as `role.assign`/`role.unassign` with `via: "people"` and the reason; a request that changes nothing (already held, or held only through a parent) succeeds without an audit row. Grant `idp:people:roles` to the backend client on its application page to enable writes. ## MCP server diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index 6c15485..e478e12 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -25,8 +25,10 @@ export type PeopleDirectory = { email: string | null; telegramId: string | null; telegramUsername: string | null; - /** The bound roles they hold, sorted. */ + /** The bound roles they hold, sorted, including through a parent role. */ roles: string[]; + /** Those of `roles` given to them directly: the only ones that can be taken back. */ + assigned: string[]; }[]; }; @@ -99,6 +101,7 @@ export async function readPeopleDirectory(): Promise { telegramId: telegramByUser.get(person.id)?.telegramId ?? null, telegramUsername: telegramByUser.get(person.id)?.telegramUsername ?? null, roles: held.get(person.id)!, + assigned: (assigned.get(person.id) ?? []).filter((key) => boundKeys.has(key)).sort(), })), }; }, @@ -121,6 +124,9 @@ export type PeopleRoleChangeInput = { * actor's own access is resolved here and checked by `peopleRoleChangeProblem`. */ export async function changePeopleRole(input: PeopleRoleChangeInput): Promise { + // Before the membership refresh, which throws a plain error for an unknown person. + const [known] = await db.select({ id: user.id }).from(user).where(eq(user.id, input.actorId)); + if (!known) throw new RbacError(403, "The acting person has no account here."); await refreshIdentityMembership(input.actorId); await db.transaction( async (transaction) => { @@ -159,15 +165,19 @@ export async function changePeopleRole(input: PeopleRoleChangeInput): Promise Date: Mon, 26 Oct 2026 10:44:15 +0000 Subject: [PATCH 4/6] fix(rbac)!: apply People role commands atomically and idempotently People now sends one command per person with the desired state of each changed concept. Auth plans the concrete assignments against its own graph, checks the actor for every one of them before writing, and applies all or none. A command that would leave the requested state held through another role is refused with 409 instead of reporting a revocation that never happened. The command is stored as a `people.roles` audit receipt, so a retry after a lost response applies nothing again and returns the current roles. The actor check now covers every bound role a role inherits, so a member role wrapping a Lead role needs people:appoint. The People directory also lists accounts with a PoliNetwork Microsoft 365 login and their object ID, so People can match Microsoft 365 members to linked accounts without relying on an address. BREAKING CHANGE: POST /api/internal/people-roles takes `{ id, actor, sub, reason, changes }` and answers with the person's bound roles, instead of `{ actor, sub, role, action, reason }` and 204. Deploy before the backend. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/auth/people-binding.test.ts | 71 +++++++ src/auth/people-binding.ts | 29 ++- src/auth/people-role-command.ts | 126 +++++++++++++ src/auth/people-roles.integration.test.ts | 149 +++++++++++++++ src/auth/people-roles.ts | 219 +++++++++++++++------- src/routes/api/internal/people-roles.ts | 24 +-- 7 files changed, 523 insertions(+), 97 deletions(-) create mode 100644 src/auth/people-role-command.ts create mode 100644 src/auth/people-roles.integration.test.ts diff --git a/README.md b/README.md index ae120db..60d1b0b 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,7 @@ Future consumers should use authorization code with PKCE, validate token signatu Master Admin can bind a hand-made role to the admin dashboard's People register on the role's page (People register card): its holders are admins of the Telegram groups (`admin`), or members or the Lead of a team (`team::member`, `team::lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. -`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles and everyone holding them by hand, with their name, directory email, Telegram ID and username, and which of those roles they were given directly (only those can be taken back); unlike the access snapshot it includes roles that grant no permission. `POST /api/internal/people-roles` (backend client, `idp:people:roles`) gives or takes back a bound role for `{ actor, sub, role, action: "assign" | "unassign", reason }`, on behalf of the dashboard user the backend authenticated. The backend only names the actor; this IdP resolves the actor's own access and refuses unless: the role is bound; the actor isn't giving themselves a role (giving one up is allowed); admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`; and, unless Master Admin, the actor holds every permission the role grants. Changes are audited as `role.assign`/`role.unassign` with `via: "people"` and the reason; a request that changes nothing (already held, or held only through a parent) succeeds without an audit row. Grant `idp:people:roles` to the backend client on its application page to enable writes. +`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, everyone holding them by hand, and everyone with a PoliNetwork Microsoft 365 account (whose roles may be empty), with their name, directory email, Microsoft 365 object ID, Telegram ID and username, and which of those roles they were given directly; unlike the access snapshot it includes roles that grant no permission. `POST /api/internal/people-roles` (backend client, `idp:people:roles`) applies one People command `{ id, actor, sub, reason, changes }` on behalf of the dashboard user the backend authenticated, where each change names a desired state: `{ kind: "admin", active }` or `{ kind: "team", team, role: "member" | "lead" | null }`. This IdP plans the concrete assignments against its own role graph and applies all of them or none: a concept with no bound role stays People's own and is skipped; it refuses with 409 when the mapping is ambiguous (not exactly one bound role to give), or when the requested state would still be held through another role, rather than reporting a change that did not happen. The backend only names the actor; this IdP resolves the actor's own access and refuses every assignment, before any write, unless: the role and every bound role it inherits are allowed to the actor (admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`); the actor isn't giving themselves a role (giving one up is allowed); and, unless Master Admin, the actor holds every permission the role grants. The response is the person's bound roles after the command (`held`, `assigned`) and the bound role list. Each assignment is audited as `role.assign`/`role.unassign` with `via: "people"`, the reason and the command ID, and the command itself as `people.roles` under `people-command:`: repeating an ID with the same command, for example after a lost response, applies nothing again and returns the current roles, and repeating it with a different command is refused with 409. Grant `idp:people:roles` to the backend client on its application page to enable writes. ## MCP server diff --git a/src/auth/people-binding.test.ts b/src/auth/people-binding.test.ts index a54e351..5795fd5 100644 --- a/src/auth/people-binding.test.ts +++ b/src/auth/people-binding.test.ts @@ -12,6 +12,7 @@ import { MASTER_ADMIN_ROLE_KEY, resolveAccess, } from "./rbac"; +import { planPeopleCommand } from "./people-role-command"; function permission(key: string, managed = false): PermissionSummary { return { @@ -161,3 +162,73 @@ describe("peopleRoleChangeProblem", () => { expect(change([MASTER_ADMIN_ROLE_KEY], "admin")).toBeNull(); }); }); + +it("checks inherited organizational authority even when it grants no extra permission", () => { + const wrapper = role("wrapper", ["people:self"], "team:it:member", ["design-lead"]); + const extended = { ...catalog, roles: [...catalog.roles, wrapper] }; + for (const action of ["assign", "unassign"] as const) { + expect( + peopleRoleChangeProblem({ + catalog: extended, + actorId: "actor", + actor: resolveAccess(extended, ["hr-member"]), + targetId: "target", + role: wrapper, + action, + }), + ).toMatch(/people:appoint/); + } +}); + +it("plans a complete revocation and rejects authority still inherited from an unbound role", () => { + const command = { + id: "00000000-0000-4000-8000-000000000001", + actor: "actor", + sub: "target", + reason: "Leaving", + changes: [{ kind: "team" as const, team: "hr", role: null }], + }; + const plan = planPeopleCommand(catalog, ["hr-lead", "hr-member"], command); + expect(plan.changes).toHaveLength(2); + expect(plan.result.held).toEqual([]); + const inherited = { + ...catalog, + roles: [...catalog.roles, role("wrapper", [], null, ["hr-lead"])], + }; + expect(() => planPeopleCommand(inherited, ["wrapper"], command)).toThrow(/inherited/); +}); + +it("demotes an inherited member correctly after taking back its directly assigned Lead", () => { + const plan = planPeopleCommand(catalog, ["hr-lead"], { + id: "00000000-0000-4000-8000-000000000002", + actor: "actor", + sub: "target", + reason: "Demotion", + changes: [{ kind: "team", team: "hr", role: "member" }], + }); + expect(plan.result.assigned).toEqual(["hr-member"]); +}); + +it("refuses missing and ambiguous assignment bindings instead of choosing a different role", () => { + const command = { + id: "00000000-0000-4000-8000-000000000003", + actor: "actor", + sub: "target", + reason: "Appointment", + changes: [{ kind: "team" as const, team: "design", role: "lead" as const }], + }; + expect(() => + planPeopleCommand( + { ...catalog, roles: catalog.roles.filter((r) => r.key !== "design-lead") }, + [], + command, + ), + ).toThrow(/exactly one/); + expect(() => + planPeopleCommand( + { ...catalog, roles: [...catalog.roles, role("another-lead", [], "team:design:lead")] }, + [], + command, + ), + ).toThrow(/exactly one/); +}); diff --git a/src/auth/people-binding.ts b/src/auth/people-binding.ts index cffb57b..661ed79 100644 --- a/src/auth/people-binding.ts +++ b/src/auth/people-binding.ts @@ -5,6 +5,7 @@ import { type RoleSummary, MASTER_ADMIN_ROLE_KEY, effectiveRolePermissions, + expandRoleKeys, isManagedPermissionKey, } from "./rbac"; @@ -90,16 +91,24 @@ export function peopleRoleChangeProblem(change: PeopleRoleChange): string | null ); return held?.kind === "team" && held.team === team && held.role === "lead"; }); - const allowed = - binding.kind === "admin" - ? general(PEOPLE_WRITE_PERMISSION) - : binding.role === "lead" - ? general(PEOPLE_APPOINT_PERMISSION) - : general(PEOPLE_WRITE_PERMISSION) || leadsTeam(binding.team); - if (!allowed) - return binding.kind === "team" && binding.role === "lead" - ? "Appointing or ending a Lead needs people:appoint and admin:access." - : "Changing this role needs people:write and admin:access, or leading the team."; + // A role grants organizational authority through every inherited binding as well as + // through permissions. A member wrapper around a Lead must require appointment rights. + for (const key of expandRoleKeys(catalog, [role.key])) { + const inherited = parsePeopleBinding( + catalog.roles.find((entry) => entry.key === key)?.peopleBinding, + ); + if (!inherited) continue; + const allowed = + inherited.kind === "admin" + ? general(PEOPLE_WRITE_PERMISSION) + : inherited.role === "lead" + ? general(PEOPLE_APPOINT_PERMISSION) + : general(PEOPLE_WRITE_PERMISSION) || leadsTeam(inherited.team); + if (!allowed) + return inherited.kind === "team" && inherited.role === "lead" + ? "Appointing or ending a Lead needs people:appoint and admin:access." + : "Changing this role needs people:write and admin:access, or leading the team."; + } if (actor.roles.includes(MASTER_ADMIN_ROLE_KEY)) return null; const missing = effectiveRolePermissions(catalog, role.key).filter((key) => !holds(key)); diff --git a/src/auth/people-role-command.ts b/src/auth/people-role-command.ts new file mode 100644 index 0000000..1c13956 --- /dev/null +++ b/src/auth/people-role-command.ts @@ -0,0 +1,126 @@ +import { z } from "zod"; +import { parsePeopleBinding, peopleBindingProblem } from "./people-binding"; +import { expandRoleKeys, type RbacCatalog } from "./rbac"; + +export const peopleCommandSchema = z + .object({ + id: z.uuid(), + actor: z.string().min(1).max(200), + sub: z.string().min(1).max(200), + reason: z.string().trim().min(1).max(500), + changes: z + .array( + z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("admin"), active: z.boolean() }), + z.object({ + kind: z.literal("team"), + team: z.string().regex(/^[a-z0-9-]{1,32}$/), + role: z.enum(["member", "lead"]).nullable(), + }), + ]), + ) + .min(1) + .max(64), + }) + .superRefine((command, ctx) => { + const keys = command.changes.map((c) => (c.kind === "admin" ? "admin" : `team:${c.team}`)); + if (new Set(keys).size !== keys.length) + ctx.addIssue({ code: "custom", message: "Each concept may appear only once" }); + }); +export type PeopleCommand = z.infer; +export type PeopleCommandResult = { + roles: { key: string; name: string; binding: string }[]; + held: string[]; + assigned: string[]; +}; + +export class PeopleCommandConflict extends Error {} + +export function peopleBoundRoles(catalog: RbacCatalog) { + return catalog.roles.filter( + (r) => parsePeopleBinding(r.peopleBinding) && !peopleBindingProblem(catalog, r), + ); +} + +export function peopleCommandResult(catalog: RbacCatalog, assigned: string[]): PeopleCommandResult { + const roles = peopleBoundRoles(catalog); + const keys = new Set(roles.map((r) => r.key)); + return { + roles: roles.map((r) => ({ key: r.key, name: r.name, binding: r.peopleBinding! })), + held: expandRoleKeys(catalog, assigned).filter((key) => keys.has(key)), + assigned: assigned.filter((key) => keys.has(key)).sort(), + }; +} + +/** Plan the entire command against Auth's graph, before writing any assignment. */ +export function planPeopleCommand( + catalog: RbacCatalog, + assigned: string[], + command: PeopleCommand, +) { + const bound = peopleBoundRoles(catalog); + const next = new Set(assigned); + const held = () => new Set(expandRoleKeys(catalog, [...next])); + const keysFor = (binding: string) => + bound.filter((r) => r.peopleBinding === binding).map((r) => r.key); + const remove = (keys: string[]) => { + for (const key of keys) next.delete(key); + }; + const give = (keys: string[], concept: string) => { + if (keys.some((key) => held().has(key))) return; + // An ambiguous mapping must be decided in Auth, never by alphabetic order. + if (keys.length !== 1) + throw new PeopleCommandConflict(`Choose exactly one assignable role for ${concept} in Auth.`); + next.add(keys[0]!); + }; + for (const change of command.changes) { + if (change.kind === "admin") { + const keys = keysFor("admin"); + if (!keys.length) continue; // An unbound concept remains local to People. + if (change.active) give(keys, "admin"); + else remove(keys); + } else { + const members = keysFor(`team:${change.team}:member`); + const leads = keysFor(`team:${change.team}:lead`); + if (!members.length && !leads.length) continue; + if (change.role !== "lead") remove(leads); + if (change.role === null) remove(members); + else give(change.role === "lead" ? leads : members, `${change.team}:${change.role}`); + } + } + const result = peopleCommandResult(catalog, [...next]); + // Removing a direct assignment may leave the same authority through another role. + // Refuse the whole command rather than reporting a revocation that never happened. + for (const change of command.changes) { + const has = (binding: string) => + bound.some((r) => r.peopleBinding === binding && result.held.includes(r.key)); + if (change.kind === "admin") { + if (keysFor("admin").length && has("admin") !== change.active) + throw new PeopleCommandConflict( + "The admin role is inherited. Change its granting role in Auth.", + ); + } else { + if (!bound.some((r) => r.peopleBinding?.startsWith(`team:${change.team}:`))) continue; + const actual = has(`team:${change.team}:lead`) + ? "lead" + : has(`team:${change.team}:member`) + ? "member" + : null; + if (actual !== change.role) + throw new PeopleCommandConflict( + `The ${change.team} role is inherited. Change its granting role in Auth.`, + ); + } + } + return { + result, + changes: [ + ...assigned + .filter((key) => !next.has(key)) + .map((role) => ({ role, action: "unassign" as const })), + ...[...next] + .filter((key) => !assigned.includes(key)) + .map((role) => ({ role, action: "assign" as const })), + ], + }; +} diff --git a/src/auth/people-roles.integration.test.ts b/src/auth/people-roles.integration.test.ts new file mode 100644 index 0000000..14d048e --- /dev/null +++ b/src/auth/people-roles.integration.test.ts @@ -0,0 +1,149 @@ +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../env", () => { + const url = new URL( + process.env.PEOPLE_ROLES_TEST_DATABASE_URL ?? "postgresql://postgres@localhost:55441/postgres", + ); + return { + env: { + DB_HOST: url.hostname, + DB_PORT: Number(url.port), + DB_USER: url.username, + DB_PASS: url.password, + DB_NAME: url.pathname.slice(1), + BETTER_AUTH_URL: "http://localhost:35441", + BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters", + STUDENT_VERIFICATION_TTL_DAYS: 365, + IDP_ADMIN_USER_IDS: ["people-root"], + PN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111", + PN_ENTRA_MEMBER_GROUP_ID: "soci", + PN_ENTRA_OIDC_ADMIN_GROUP_ID: "admins", + }, + }; +}); +vi.mock("./membership", () => ({ + checkEntraGroupMember: vi.fn().mockResolvedValue(false), + checkPnGroupStates: vi.fn().mockResolvedValue([]), + listEntraGroupMembers: vi.fn().mockResolvedValue(null), +})); + +import { db } from "../db/index"; +import { changePeopleRoles, readPeopleDirectory } from "./people-roles"; +import { assignRole, saveRole, setRolePeopleBinding } from "./rbac-store"; + +const root = "people-root"; +const hr = "people-hr"; +const target = "people-target"; +const team = `t${randomUUID().slice(0, 8)}`; + +describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( + "People role commands with PostgreSQL", + () => { + const pool = new Pool({ connectionString: process.env.PEOPLE_ROLES_TEST_DATABASE_URL }); + const roles: Record = {}; + const command = (changes: Parameters[0]["changes"]) => ({ + id: randomUUID(), + actor: hr, + sub: target, + reason: "Integration test", + changes, + }); + const assigned = async () => + ( + await pool.query<{ key: string }>( + `SELECT r.key FROM user_role ur JOIN role r ON r.id = ur.role_id WHERE ur.user_id = $1 ORDER BY r.key`, + [target], + ) + ).rows.map((row) => row.key); + + beforeAll(async () => { + await pool.query( + `INSERT INTO "user" (id, name, email) SELECT id, id, id || '@identity.invalid' FROM unnest($1::text[]) AS id`, + [[root, hr, target]], + ); + await pool.query( + `INSERT INTO permission (id, key, name) VALUES ($1, 'people:write', 'people:write'), ($2, 'admin:access', 'admin:access') + ON CONFLICT DO NOTHING`, + [randomUUID(), randomUUID()], + ); + const role = async (key: string, permissions: string[], parents: string[] = []) => { + const saved = await saveRole(root, { + key, + name: key, + description: "", + permissions, + parents, + }); + roles[key] = saved.id; + return saved; + }; + await role(`${team}-hr`, ["people:write", "admin:access"]); + await role(`${team}-member`, []); + await role(`${team}-lead`, [], [`${team}-member`]); + await role(`${team}-admin`, []); + await setRolePeopleBinding(root, roles[`${team}-admin`]!, "admin"); + await setRolePeopleBinding(root, roles[`${team}-member`]!, `team:${team}:member`); + await setRolePeopleBinding(root, roles[`${team}-lead`]!, `team:${team}:lead`); + await assignRole(root, roles[`${team}-hr`]!, hr); + }); + + afterAll(async () => { + await pool.query(`DELETE FROM "user" WHERE id = ANY($1)`, [[root, hr, target]]); + await pool.query(`DELETE FROM role WHERE key LIKE $1`, [`${team}-%`]); + await pool.end(); + await db.$client.end(); + }); + + it("applies a command once, replays it after a lost response and refuses a reused ID", async () => { + const add = command([{ kind: "team", team, role: "member" }]); + expect(await changePeopleRoles(add)).toMatchObject({ assigned: [`${team}-member`] }); + // A retry returns the current state and writes nothing more. + const events = await pool.query(`SELECT count(*)::int AS n FROM rbac_audit_event`); + expect(await changePeopleRoles(structuredClone(add))).toMatchObject({ + assigned: [`${team}-member`], + }); + expect((await pool.query(`SELECT count(*)::int AS n FROM rbac_audit_event`)).rows[0].n).toBe( + events.rows[0].n, + ); + await expect( + changePeopleRoles({ ...add, changes: [{ kind: "team", team, role: null }] }), + ).rejects.toMatchObject({ status: 409 }); + expect(await assigned()).toEqual([`${team}-member`]); + }); + + it("writes nothing when one of the assignments is refused", async () => { + // people:write but not people:appoint: the Lead is refused, so the admin role is too. + await expect( + changePeopleRoles( + command([ + { kind: "admin", active: true }, + { kind: "team", team, role: "lead" }, + ]), + ), + ).rejects.toMatchObject({ status: 403 }); + expect(await assigned()).toEqual([`${team}-member`]); + }); + + it("refuses to report a revocation that an unbound parent role keeps in place", async () => { + const wrapper = await saveRole(root, { + key: `${team}-wrapper`, + name: "wrapper", + description: "", + permissions: [], + parents: [`${team}-member`], + }); + await assignRole(root, wrapper.id, target); + await expect( + changePeopleRoles(command([{ kind: "team", team, role: null }])), + ).rejects.toMatchObject({ status: 409, message: expect.stringContaining("inherited") }); + expect(await assigned()).toEqual([`${team}-member`, `${team}-wrapper`]); + const directory = await readPeopleDirectory(); + expect(directory.users.find((entry) => entry.sub === target)).toMatchObject({ + roles: [`${team}-member`], + assigned: [`${team}-member`], + }); + }); + }, +); diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index e478e12..830bb8b 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -11,18 +11,30 @@ import { peopleBindingProblem, peopleRoleChangeProblem, } from "./people-binding"; +import { + type PeopleCommand, + type PeopleCommandResult, + PeopleCommandConflict, + planPeopleCommand, + peopleCommandResult, +} from "./people-role-command"; import { expandRoleKeys, resolveAccess } from "./rbac"; import { RbacError, assignedRoleKeys, auditSnapshot, readCatalog } from "./rbac-store"; export type PeopleDirectory = { /** Every role People manages, with what holding it means. */ roles: { key: string; name: string; binding: string }[]; - /** Everyone holding at least one of those roles, by hand or through a parent role. */ + /** + * Everyone holding at least one of those roles, by hand or through a parent role, and + * everyone with a PoliNetwork Microsoft 365 account, whose `roles` may be empty. + */ users: { sub: string; name: string; /** Their PoliNetwork address when they linked it, otherwise their sign-in address. */ email: string | null; + /** Their PoliNetwork Microsoft 365 object ID, when they linked that account. */ + azureId: string | null; telegramId: string | null; telegramUsername: string | null; /** The bound roles they hold, sorted, including through a parent role. */ @@ -65,9 +77,25 @@ export async function readPeopleDirectory(): Promise { const roles = expandRoleKeys(catalog, keys).filter((key) => boundKeys.has(key)); if (roles.length) held.set(userId, roles); } - const ids = [...held.keys()]; + // Also every PoliNetwork Microsoft 365 account, bound role or not: People matches its + // Microsoft 365 members to linked accounts by that stable ID instead of an address. + const entra = await transaction + .select({ userId: account.userId, azureId: identityEvidence.externalId }) + .from(account) + .innerJoin( + identityEvidence, + and( + eq(account.issuer, identityEvidence.issuer), + eq(account.accountId, identityEvidence.subject), + eq(account.providerId, identityEvidence.providerId), + ), + ) + .where(eq(account.providerId, "pn-entra")); + const azureByUser = new Map( + entra.flatMap((row) => (row.azureId ? [[row.userId, row.azureId] as const] : [])), + ); + const ids = [...new Set([...held.keys(), ...azureByUser.keys()])]; if (!ids.length) return { roles, users: [] }; - const people = await transaction .select({ id: user.id, name: user.name, email: user.email }) .from(user) @@ -98,9 +126,10 @@ export async function readPeopleDirectory(): Promise { sub: person.id, name: person.name, email: emails.get(person.id) ?? null, + azureId: azureByUser.get(person.id) ?? null, telegramId: telegramByUser.get(person.id)?.telegramId ?? null, telegramUsername: telegramByUser.get(person.id)?.telegramUsername ?? null, - roles: held.get(person.id)!, + roles: held.get(person.id) ?? [], assigned: (assigned.get(person.id) ?? []).filter((key) => boundKeys.has(key)).sort(), })), }; @@ -109,85 +138,141 @@ export async function readPeopleDirectory(): Promise { ); } -export type PeopleRoleChangeInput = { - /** The dashboard user the backend acts for, as authenticated by the backend. */ - actorId: string; - targetId: string; - roleKey: string; - action: "assign" | "unassign"; - reason: string; -}; +type Reader = Pick; +type Transaction = Parameters[0]>[0]; + +/** Same command, same canonical form, whatever the key order jsonb stored it in. */ +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") + return `{${Object.entries(value) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`) + .join(",")}}`; + return JSON.stringify(value); +} /** - * Gives or takes back a role People manages, on behalf of a dashboard user. The backend is - * trusted to name the actor it authenticated, never to decide what that actor may do: the - * actor's own access is resolved here and checked by `peopleRoleChangeProblem`. + * Applies one People command on behalf of a dashboard user: every assignment it needs, or + * none. The backend names the desired organizational state; Auth plans the concrete + * assignments against its own graph and checks the actor's own access for each of them + * with `peopleRoleChangeProblem`. A receipt committed with the assignments makes a retry + * after a lost response safe: it returns the person's current roles without applying the + * command again, even if the actor's access has changed since. */ -export async function changePeopleRole(input: PeopleRoleChangeInput): Promise { +export async function changePeopleRoles(input: PeopleCommand): Promise { + const receiptId = `people-command:${input.id}`; + const current = async (reader: Reader) => { + const catalog = await readCatalog(reader); + return peopleCommandResult(catalog, await assignedRoleKeys(input.sub, catalog, reader)); + }; + // Must run after the lock: a concurrent attempt of the same command may be committing. + const replay = async (reader: Reader) => { + const [receipt] = await reader + .select({ operation: rbacAuditEvent.operation, before: rbacAuditEvent.before }) + .from(rbacAuditEvent) + .where(eq(rbacAuditEvent.id, receiptId)); + if (!receipt) return null; + if (receipt.operation !== "people.roles" || canonical(receipt.before) !== canonical(input)) + throw new RbacError(409, "This command ID was already used with different input."); + // The current roles, not those of the receipt: a later change must not be undone in People. + return current(reader); + }; + const transaction = (fn: (transaction: Transaction) => Promise) => + db.transaction( + async (transaction) => { + await transaction.execute(authorizationMutationLock); + return fn(transaction); + }, + { isolationLevel: "read committed" }, + ); + + const [receipt] = await db + .select({ id: rbacAuditEvent.id }) + .from(rbacAuditEvent) + .where(eq(rbacAuditEvent.id, receiptId)); + if (receipt) return transaction(async (tx) => (await replay(tx))!); + // Before the membership refresh, which throws a plain error for an unknown person. - const [known] = await db.select({ id: user.id }).from(user).where(eq(user.id, input.actorId)); + const [known] = await db.select({ id: user.id }).from(user).where(eq(user.id, input.actor)); if (!known) throw new RbacError(403, "The acting person has no account here."); - await refreshIdentityMembership(input.actorId); - await db.transaction( - async (transaction) => { - await transaction.execute(authorizationMutationLock); - const catalog = await readCatalog(transaction); - const target = catalog.roles.find((entry) => entry.key === input.roleKey); - if (!target) throw new RbacError(404, "Role not found."); - const [actor] = await transaction - .select({ id: user.id }) - .from(user) - .where(eq(user.id, input.actorId)); - if (!actor) throw new RbacError(403, "The acting person has no account here."); - const [person] = await transaction - .select({ id: user.id }) - .from(user) - .where(eq(user.id, input.targetId)); - if (!person) throw new RbacError(404, "That person was not found."); + await refreshIdentityMembership(input.actor); + return transaction(async (tx) => { + const replayed = await replay(tx); + if (replayed) return replayed; + const catalog = await readCatalog(tx); + const [actor] = await tx.select({ id: user.id }).from(user).where(eq(user.id, input.actor)); + if (!actor) throw new RbacError(403, "The acting person has no account here."); + const [person] = await tx.select({ id: user.id }).from(user).where(eq(user.id, input.sub)); + if (!person) throw new RbacError(404, "That person was not found."); - const subject = await readIdentitySubject(input.actorId, transaction); - const access = resolveAccess(catalog, [ - ...(await assignedRoleKeys(input.actorId, catalog, transaction)), - ...subject.roleKeys, - ]); + let plan; + try { + plan = planPeopleCommand(catalog, await assignedRoleKeys(input.sub, catalog, tx), input); + } catch (error) { + if (error instanceof PeopleCommandConflict) throw new RbacError(409, error.message); + throw error; + } + const subject = await readIdentitySubject(input.actor, tx); + const access = resolveAccess(catalog, [ + ...(await assignedRoleKeys(input.actor, catalog, tx)), + ...subject.roleKeys, + ]); + // Every assignment is checked against the actor's access before the first write. + const steps = plan.changes.map((change) => ({ + ...change, + target: catalog.roles.find((entry) => entry.key === change.role)!, + })); + for (const step of steps) { const problem = peopleRoleChangeProblem({ catalog, - actorId: input.actorId, + actorId: input.actor, actor: access, - targetId: input.targetId, - role: target, - action: input.action, + targetId: input.sub, + role: step.target, + action: step.action, }); if (problem) { - logAuthorizationDenial(input.actorId, "people-roles", [`${input.action}:${target.key}`]); + logAuthorizationDenial(input.actor, "people-roles", [`${step.action}:${step.target.key}`]); throw new RbacError(403, problem); } + } - const operation = input.action === "assign" ? "role.assign" : "role.unassign"; - const before = await auditSnapshot(transaction, catalog, operation, target.id); - const changed = - input.action === "assign" - ? await transaction - .insert(userRole) - .values({ roleId: target.id, userId: input.targetId, assignedBy: input.actorId }) - .onConflictDoNothing() - .returning({ userId: userRole.userId }) - : await transaction - .delete(userRole) - .where(and(eq(userRole.roleId, target.id), eq(userRole.userId, input.targetId))) - .returning({ userId: userRole.userId }); - // Already held, or held only through a parent role: nothing happened, so nothing is audited. - if (!changed.length) return; - const after = await auditSnapshot(transaction, catalog, operation, target.id); - await transaction.insert(rbacAuditEvent).values({ + for (const step of steps) { + const operation = step.action === "assign" ? "role.assign" : "role.unassign"; + const before = await auditSnapshot(tx, catalog, operation, step.target.id); + if (step.action === "assign") + await tx + .insert(userRole) + .values({ roleId: step.target.id, userId: input.sub, assignedBy: input.actor }); + else + await tx + .delete(userRole) + .where(and(eq(userRole.roleId, step.target.id), eq(userRole.userId, input.sub))); + const after = await auditSnapshot(tx, catalog, operation, step.target.id); + await tx.insert(rbacAuditEvent).values({ id: randomUUID(), - actorId: input.actorId, + actorId: input.actor, operation, - targetId: target.id, + targetId: step.target.id, before, - after: { ...after, via: "people", userId: input.targetId, reason: input.reason }, + after: { + ...after, + via: "people", + userId: input.sub, + reason: input.reason, + commandId: input.id, + }, }); - }, - { isolationLevel: "read committed" }, - ); + } + await tx.insert(rbacAuditEvent).values({ + id: receiptId, + actorId: input.actor, + operation: "people.roles", + targetId: input.sub, + before: input, + after: plan.result, + }); + return plan.result; + }); } diff --git a/src/routes/api/internal/people-roles.ts b/src/routes/api/internal/people-roles.ts index 6e12384..345164a 100644 --- a/src/routes/api/internal/people-roles.ts +++ b/src/routes/api/internal/people-roles.ts @@ -1,21 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; -import { z } from "zod"; +import { peopleCommandSchema } from "@/auth/people-role-command"; import { serviceClientId } from "@/auth/access-snapshot-token"; import { projectionForClient } from "@/auth/access-projections"; -import { changePeopleRole, readPeopleDirectory } from "@/auth/people-roles"; +import { changePeopleRoles, readPeopleDirectory } from "@/auth/people-roles"; import { RbacError } from "@/auth/rbac-store"; import { env } from "@/env"; const headers = { "Cache-Control": "private, no-store", Vary: "Authorization" }; -const changeSchema = z.object({ - actor: z.string().min(1).max(200), - sub: z.string().min(1).max(200), - role: z.string().min(1).max(200), - action: z.enum(["assign", "unassign"]), - reason: z.string().trim().max(500), -}); - /** Only the backend's own client, with the scope the request needs. */ async function backendClient(request: Request, scope: "idp:access:read" | "idp:people:roles") { if (!env.OAUTH_BACKEND_CLIENT_ID || !env.OAUTH_INTERNAL_RESOURCE_URI) @@ -43,18 +35,12 @@ export const Route = createFileRoute("/api/internal/people-roles")({ POST: async ({ request }) => { const refused = await backendClient(request, "idp:people:roles"); if (refused) return refused; - const parsed = changeSchema.safeParse(await request.json().catch(() => null)); + const parsed = peopleCommandSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400, headers }); try { - await changePeopleRole({ - actorId: parsed.data.actor, - targetId: parsed.data.sub, - roleKey: parsed.data.role, - action: parsed.data.action, - reason: parsed.data.reason, - }); - return new Response(null, { status: 204, headers }); + const result = await changePeopleRoles(parsed.data); + return Response.json(result, { headers }); } catch (error) { if (error instanceof RbacError) return Response.json({ error: error.message }, { status: error.status, headers }); From ce030c51a824e6ea0b69ec9ddd5c6a6f77f09062 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 30 Oct 2026 15:24:57 +0100 Subject: [PATCH 5/6] feat(people)!: bind Capo Admins, delegate binding with idp:people:bind and drop Soci Rework of #73 for #78, after dropping #75's preliminary people. - Add the `capo` binding for Capo Admins: pattern, check constraint, parser and `{ kind: "capo", active }` commands. Appointing or ending one needs `admin:access` + `people:appoint`; `capo-admin` inherits `admin`, so `people:write` is required too. - Binding a role to People needs the new managed permission `idp:people:bind` (always implies `idp:roles:read`) instead of Master Admin, bounded like role edits: only Master Admin binds a role granting permissions they don't hold. The role page card and the MCP tool follow it. - Restructure delegation as one rule per binding kind (`PEOPLE_RULES`), so a later "Capo Admins manage their admins" rule is a local change. - `GET /api/internal/people-roles` lists only holders of bound roles: no Microsoft 365 accounts or `azureId`. It reports `since` per held role. - README: People roles and the deployment steps from #79. Folded into the unreleased migration 0023. Closes #78 --- README.md | 78 +++++++++++++---- drizzle/0023_role_people_binding.sql | 14 ++- drizzle/meta/0023_snapshot.json | 2 +- src/auth/mcp-server.ts | 8 +- src/auth/oidc-clients.ts | 2 +- src/auth/people-binding.test.ts | 85 ++++++++++++++++++- src/auth/people-binding.ts | 94 +++++++++++++++------ src/auth/people-role-command.ts | 21 +++-- src/auth/people-roles.integration.test.ts | 85 ++++++++++++++++++- src/auth/people-roles.ts | 64 ++++++-------- src/auth/rbac-store.ts | 19 ++--- src/auth/rbac.functions.ts | 4 +- src/auth/rbac.test.ts | 1 + src/auth/rbac.ts | 9 ++ src/components/rbac/people-binding-card.tsx | 10 ++- src/db/rbac.ts | 6 +- src/routes/access/roles/$roleId.tsx | 5 +- src/routes/api/internal/people-roles.ts | 2 +- 18 files changed, 385 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 60d1b0b..85e8456 100644 --- a/README.md +++ b/README.md @@ -158,30 +158,31 @@ when that is what you mean. ### Permissions the identity provider defines itself Administering this service is expressed as permissions like any other capability, so it can -be delegated to a role instead of being wired to a single group. These eleven always exist +be delegated to a role instead of being wired to a single group. These twelve always exist and can never be created, deleted, or rekeyed, because the code checks for these exact keys; which roles carry them is entirely up to you. -| Permission | Covers | -| ------------------------ | ------------------------------------------------------------ | -| `idp:people:read` | Searching the people registered here | -| `idp:users:read` | Browsing the user directory at `/users` | -| `idp:users:delete` | Permanently deleting someone's account | -| `idp:permissions:read` | Seeing permissions in the `/access` section | -| `idp:permissions:write` | Creating, changing, and deleting permissions | -| `idp:roles:read` | Seeing roles, what they grant, and who holds them | -| `idp:roles:write` | Creating, changing, and deleting roles | -| `idp:roles:assign` | Giving roles to people and taking them away | -| `idp:applications:read` | Seeing the OIDC applications at `/applications` | -| `idp:applications:write` | Registering and editing applications, and rotating secrets | -| `idp:mcp` | Connecting an AI agent through the [MCP server](#mcp-server) | +| Permission | Covers | +| ------------------------ | ----------------------------------------------------------------- | +| `idp:people:read` | Searching the people registered here | +| `idp:users:read` | Browsing the user directory at `/users` | +| `idp:users:delete` | Permanently deleting someone's account | +| `idp:permissions:read` | Seeing permissions in the `/access` section | +| `idp:permissions:write` | Creating, changing, and deleting permissions | +| `idp:roles:read` | Seeing roles, what they grant, and who holds them | +| `idp:roles:write` | Creating, changing, and deleting roles | +| `idp:roles:assign` | Giving roles to people and taking them away | +| `idp:people:bind` | Choosing which roles the [People register](#people-roles) manages | +| `idp:applications:read` | Seeing the OIDC applications at `/applications` | +| `idp:applications:write` | Registering and editing applications, and rotating secrets | +| `idp:mcp` | Connecting an AI agent through the [MCP server](#mcp-server) | They use the permission hierarchy themselves: each `write` grants its `read`, `idp:roles:assign` grants `idp:roles:read` and `idp:people:read` so an assigner can see the role and find who to give it to, `idp:roles:write` also grants `idp:people:read`, `idp:users:read` grants `idp:people:read` because browsing everyone includes finding them, and `idp:roles:read` grants `idp:permissions:read` because a role is meaningless without seeing the permissions it carries. A role with `idp:roles:write` therefore ends up with four permissions and still cannot touch applications. `idp:roles:write` granting -`idp:roles:read`, `idp:roles:assign` granting `idp:roles:read`, `idp:permissions:write` +`idp:roles:read`, `idp:roles:assign` and `idp:people:bind` granting `idp:roles:read`, `idp:permissions:write` granting `idp:permissions:read` and `idp:users:delete` granting `idp:users:read` are fixed in code: they apply even if the stored edge is missing, and cannot be removed, because changing either without seeing what already exists makes no sense. The other implications @@ -340,9 +341,49 @@ Future consumers should use authorization code with PKCE, validate token signatu ### People roles -Master Admin can bind a hand-made role to the admin dashboard's People register on the role's page (People register card): its holders are admins of the Telegram groups (`admin`), or members or the Lead of a team (`team::member`, `team::lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. +The admin dashboard's People register gives people predetermined roles: those bound, on the role's page (People register card), to what holding them means in People. A role's holders are admins of the Telegram groups (`admin`), Capo Admins (`capo`), or members or the Lead of a team (`team::member`, `team::lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Binding a role needs `idp:people:bind`, and, as when editing a role, only Master Admin can bind or unbind a role granting permissions they don't hold. Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. Bind only one role to each meaning: `trusted-admin` inherits `admin`, so binding it as `admin` too makes giving `admin` ambiguous (409). -`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, everyone holding them by hand, and everyone with a PoliNetwork Microsoft 365 account (whose roles may be empty), with their name, directory email, Microsoft 365 object ID, Telegram ID and username, and which of those roles they were given directly; unlike the access snapshot it includes roles that grant no permission. `POST /api/internal/people-roles` (backend client, `idp:people:roles`) applies one People command `{ id, actor, sub, reason, changes }` on behalf of the dashboard user the backend authenticated, where each change names a desired state: `{ kind: "admin", active }` or `{ kind: "team", team, role: "member" | "lead" | null }`. This IdP plans the concrete assignments against its own role graph and applies all of them or none: a concept with no bound role stays People's own and is skipped; it refuses with 409 when the mapping is ambiguous (not exactly one bound role to give), or when the requested state would still be held through another role, rather than reporting a change that did not happen. The backend only names the actor; this IdP resolves the actor's own access and refuses every assignment, before any write, unless: the role and every bound role it inherits are allowed to the actor (admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`); the actor isn't giving themselves a role (giving one up is allowed); and, unless Master Admin, the actor holds every permission the role grants. The response is the person's bound roles after the command (`held`, `assigned`) and the bound role list. Each assignment is audited as `role.assign`/`role.unassign` with `via: "people"`, the reason and the command ID, and the command itself as `people.roles` under `people-command:`: repeating an ID with the same command, for example after a lost response, applies nothing again and returns the current roles, and repeating it with a different command is refused with 409. Grant `idp:people:roles` to the backend client on its application page to enable writes. +`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles and everyone holding them by hand or through a parent role, with their name, directory email, Telegram ID and username, which of those roles they were given directly, and since when they hold each one (`since`, `YYYY-MM-DD`: the earliest of their current assignments conferring it). Unlike the access snapshot it includes roles that grant no permission. People without a login account are the backend's own: they never appear here. + +`POST /api/internal/people-roles` (backend client, `idp:people:roles`) applies one People command `{ id, actor, sub, reason, changes }` on behalf of the dashboard user the backend authenticated. `sub` is always a login account. Each change names a desired state: `{ kind: "admin" | "capo", active }` or `{ kind: "team", team, role: "member" | "lead" | null }`. This IdP plans the concrete assignments against its own role graph and applies all of them or none: a concept with no bound role stays People's own and is skipped; it refuses with 409 when the mapping is ambiguous (not exactly one bound role to give), or when the requested state would still be held through another role (ending `admin` for a Capo Admin, say), rather than reporting a change that did not happen. + +The backend only names the actor; this IdP resolves the actor's own access and refuses every assignment, before any write, unless: + +- the actor isn't giving themselves a role (giving one up is allowed). When an invitee accepts an invite, the backend sends whoever made each assignment as the actor; +- the rule for the role's binding, and for every bound role it inherits, allows it (`PEOPLE_RULES` in `src/auth/people-binding.ts`): + + | Binding | Who may give or take it back | + | ------------------- | ------------------------------------------------------------------------ | + | `admin` | `admin:access` + `people:write` | + | `capo` | `admin:access` + `people:appoint` (and, through `admin`, `people:write`) | + | `team::member` | `admin:access` + `people:write`, or the Lead of that team | + | `team::lead` | `admin:access` + `people:appoint` | + +- unless Master Admin, the actor holds every permission the role grants. + +The response is the person's bound roles after the command (`held`, `assigned`) and the bound role list. Each assignment is audited as `role.assign`/`role.unassign` with `via: "people"`, the reason and the command ID, and the command itself as `people.roles` under `people-command:`: repeating an ID with the same command, for example after a lost response, applies nothing again and returns the current roles, and repeating it with a different command is refused with 409. + +#### Deploying People roles + +Deploy this before the backend and dashboard that use it, then (PoliNetworkOrg/auth#79): + +1. Migrate (`0023_role_people_binding`). It adds the binding column and `idp:people:bind`, which only Master Admin holds until you grant it to a role. +2. Grant `idp:people:bind` to whoever should configure People, if anyone besides Master Admin. +3. Bind the roles. Team keys must match the backend's team list: + + | Role | Binding | + | ------------------------------- | ----------------------------------------- | + | `admin` | `admin` | + | `capo-admin` | `capo` | + | `hr-member` / `hr-lead` | `team:hr:member` / `team:hr:lead` | + | `design-member` / `design-lead` | `team:design:member` / `team:design:lead` | + | `intl-member` / `intl-lead` | `team:intl:member` / `team:intl:lead` | + | `events-member` / `events-lead` | `team:events:member` / `team:events:lead` | + + Leave `trusted-admin` unbound: it inherits `admin` and stays assigned by hand. `it-core` and `it-lead` carry `idp:*` permissions, so they can't be bound; the IT team needs plain team roles of its own (see #79). + +4. Grant the internal scope `idp:people:roles` to the backend client on its application page. Reads work without it. +5. Send one role command from the backend for a test account, and check that the audit log shows `via: people`. ## MCP server @@ -377,7 +418,8 @@ Every tool calls the same code as the matching page, which checks the permission | `idp:roles:write` | `create_role`, `update_role`, `delete_role` | | `idp:roles:assign` | `assign_role`, `unassign_role` | | `idp:permissions:write` | `create_permission`, `update_permission`, `delete_permission` | -| Master Admin | `list_audit_events`, `set_role_people_binding` | +| `idp:people:bind` | `set_role_people_binding` | +| Master Admin | `list_audit_events` | Deleting people is left to the website. Deletes ask the agent to repeat the name or key. New client secrets go back to the agent once, like on the page, so it can hand them over. diff --git a/drizzle/0023_role_people_binding.sql b/drizzle/0023_role_people_binding.sql index 7a9c79d..ccbcf69 100644 --- a/drizzle/0023_role_people_binding.sql +++ b/drizzle/0023_role_people_binding.sql @@ -1,2 +1,14 @@ ALTER TABLE "role" ADD COLUMN "people_binding" text;--> statement-breakpoint -ALTER TABLE "role" ADD CONSTRAINT "role_people_binding_check" CHECK ("role"."people_binding" is null or (not "role"."managed" and "role"."people_binding" ~ '^(admin|team:[a-z0-9-]{1,32}:(member|lead))$')); \ No newline at end of file +ALTER TABLE "role" ADD CONSTRAINT "role_people_binding_check" CHECK ("role"."people_binding" is null or (not "role"."managed" and "role"."people_binding" ~ '^(admin|capo|team:[a-z0-9-]{1,32}:(member|lead))$'));--> statement-breakpoint +-- Binding a role to People is delegated like every other administrative right. No role is +-- given it; Master Admin covers the new key through its wildcard. +INSERT INTO "permission" ("id", "key", "name", "description", "managed") VALUES + ('managed-permission-idp-people-bind', 'idp:people:bind', 'Bind roles to People', 'Choose which roles the admin dashboard''s People register manages, and what holding each one means there.', true) +ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint +-- Binding a role means seeing what it grants. +INSERT INTO "permission_implication" ("permission_id", "implied_permission_id") +SELECT bind.id, implied.id +FROM "permission" bind +JOIN "permission" implied ON implied.key = 'idp:roles:read' +WHERE bind.key = 'idp:people:bind' +ON CONFLICT DO NOTHING; diff --git a/drizzle/meta/0023_snapshot.json b/drizzle/meta/0023_snapshot.json index 5118532..67ad404 100644 --- a/drizzle/meta/0023_snapshot.json +++ b/drizzle/meta/0023_snapshot.json @@ -2127,7 +2127,7 @@ "checkConstraints": { "role_people_binding_check": { "name": "role_people_binding_check", - "value": "\"role\".\"people_binding\" is null or (not \"role\".\"managed\" and \"role\".\"people_binding\" ~ '^(admin|team:[a-z0-9-]{1,32}:(member|lead))$')" + "value": "\"role\".\"people_binding\" is null or (not \"role\".\"managed\" and \"role\".\"people_binding\" ~ '^(admin|capo|team:[a-z0-9-]{1,32}:(member|lead))$')" } }, "isRLSEnabled": false diff --git a/src/auth/mcp-server.ts b/src/auth/mcp-server.ts index 7dd4db0..7a40556 100644 --- a/src/auth/mcp-server.ts +++ b/src/auth/mcp-server.ts @@ -835,13 +835,13 @@ export function createMcpServer(principal: McpPrincipal) { ); } - if (masterAdmin) { + if (can("idp:people:bind")) { server.registerTool( offer("set_role_people_binding"), { title: "Manage a role from People", description: - "Binds a hand-made role to the admin dashboard's People register, or unbinds it with null. Holders of a bound role appear in People, and whoever may change that part of People can give or take it back, never to themselves and never beyond the permissions they hold. Roles carrying idp:* permissions can't be bound. Master Admin only.", + "Binds a hand-made role to the admin dashboard's People register, or unbinds it with null. Holders of a bound role appear in People, and whoever may change that part of People can give or take it back, never to themselves and never beyond the permissions they hold. Roles carrying idp:* permissions can't be bound, and only Master Admin can bind or unbind a role granting permissions you don't hold.", inputSchema: z.object({ role: roleRef, binding: z @@ -849,7 +849,7 @@ export function createMcpServer(principal: McpPrincipal) { .max(64) .nullable() .describe( - "admin (Telegram group admins), team::member or team::lead, with the dashboard's team key: it, hr, design, intl or events.", + "admin (Telegram group admins), capo (Capo Admins), team::member or team::lead, with the dashboard's team key: it, hr, design, intl or events.", ), }), annotations: { idempotentHint: true }, @@ -860,7 +860,9 @@ export function createMcpServer(principal: McpPrincipal) { return setRolePeopleBinding(actor, current.id, binding); }), ); + } + if (masterAdmin) { server.registerTool( offer("list_audit_events"), { diff --git a/src/auth/oidc-clients.ts b/src/auth/oidc-clients.ts index de40197..e30a1f5 100644 --- a/src/auth/oidc-clients.ts +++ b/src/auth/oidc-clients.ts @@ -126,7 +126,7 @@ export const apiScopes = [ id: "idp:people:roles", label: "People roles", description: - "Give and take back the roles Master Admin bound to People, on behalf of a signed-in person who may change them.", + "Give and take back the roles bound to People, on behalf of a signed-in person who may change them.", resource: "internal", }, ] as const; diff --git a/src/auth/people-binding.test.ts b/src/auth/people-binding.test.ts index 5795fd5..12c1a00 100644 --- a/src/auth/people-binding.test.ts +++ b/src/auth/people-binding.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; import { type PeopleRoleChange, + PEOPLE_RULES, + describePeopleBinding, parsePeopleBinding, peopleBindingProblem, peopleRoleChangeProblem, @@ -79,6 +81,9 @@ const catalog: RbacCatalog = { role("design-member", ["people:self"], "team:design:member"), role("design-lead", ["people:self"], "team:design:lead"), role("admin", ["tg:trusted"], "admin"), + role("capo-admin", ["people:self"], "capo", ["admin"]), + // Appoints Capo Admins, but can't add admins on its own. + role("appointer", ["admin:access", "people:appoint", "people:self", "tg:trusted"]), role("sneaky", ["idp:roles:write"], "team:it:member"), role("unbound", ["people:self"]), ], @@ -103,8 +108,10 @@ function change( } describe("parsePeopleBinding", () => { - it("reads admin and team bindings", () => { + it("reads admin, capo and team bindings", () => { expect(parsePeopleBinding("admin")).toEqual({ kind: "admin" }); + expect(parsePeopleBinding("capo")).toEqual({ kind: "capo" }); + expect(describePeopleBinding({ kind: "capo" })).toBe("Capo Admin"); expect(parsePeopleBinding("team:events:lead")).toEqual({ kind: "team", team: "events", @@ -113,7 +120,15 @@ describe("parsePeopleBinding", () => { }); it("rejects anything else", () => { - for (const value of [null, "", "team:it", "team:IT:member", "team:it:owner", "admins"]) + for (const value of [ + null, + "", + "team:it", + "team:IT:member", + "team:it:owner", + "admins", + "capo:x", + ]) expect(parsePeopleBinding(value)).toBeNull(); }); }); @@ -155,6 +170,24 @@ describe("peopleRoleChangeProblem", () => { expect(change(["hr-member"], "hr-lead")).toMatch(/people:appoint/); }); + it("needs people:appoint for Capo Admins, and people:write for the admin role they inherit", () => { + expect(change(["direttivo", "admin"], "capo-admin")).toBeNull(); + expect(change(["direttivo", "admin"], "capo-admin", { action: "unassign" })).toBeNull(); + expect(change(["hr-member", "admin"], "capo-admin")).toMatch(/Capo Admin needs people:appoint/); + expect(change(["appointer"], "capo-admin")).toMatch(/people:write/); + expect(change(["capo-admin"], "capo-admin")).toMatch(/people:appoint/); + }); + + it("has one delegation rule per binding kind", () => { + expect(Object.keys(PEOPLE_RULES).sort()).toEqual(["admin", "capo", "team"]); + }); + + it("lets whoever made an assignment apply it when the invitee accepts", () => { + // The backend sends the assigner as the actor: the invitee would be giving themselves a role. + expect(change(["direttivo"], "design-member", { actorId: "assigner" })).toBeNull(); + expect(change(["direttivo"], "design-member", { actorId: "target" })).toMatch(/themselves/); + }); + it("refuses roles granting permissions the actor lacks, except to Master Admin", () => { expect(change(["hr-member"], "admin")).toMatch(/tg:trusted/); expect(change(["direttivo"], "admin", { action: "unassign" })).toMatch(/tg:trusted/); @@ -232,3 +265,51 @@ it("refuses missing and ambiguous assignment bindings instead of choosing a diff ), ).toThrow(/exactly one/); }); + +describe("planPeopleCommand with Capo Admins", () => { + const command = (changes: Parameters[2]["changes"]) => ({ + id: "00000000-0000-4000-8000-000000000004", + actor: "actor", + sub: "target", + reason: "Capo", + changes, + }); + + it("appoints and ends a Capo Admin, who holds admin through it", () => { + const appointed = planPeopleCommand(catalog, [], command([{ kind: "capo", active: true }])); + expect(appointed.changes).toEqual([{ role: "capo-admin", action: "assign" }]); + expect(appointed.result.held).toEqual(["admin", "capo-admin"]); + const ended = planPeopleCommand( + catalog, + ["admin", "capo-admin"], + command([{ kind: "capo", active: false }]), + ); + expect(ended.changes).toEqual([{ role: "capo-admin", action: "unassign" }]); + expect(ended.result.held).toEqual(["admin"]); + }); + + it("refuses to end the admin role a Capo Admin still holds through Capo", () => { + expect(() => + planPeopleCommand(catalog, ["capo-admin"], command([{ kind: "admin", active: false }])), + ).toThrow(/admin role is inherited/); + const both = planPeopleCommand( + catalog, + ["capo-admin"], + command([ + { kind: "capo", active: false }, + { kind: "admin", active: false }, + ]), + ); + expect(both.result.held).toEqual([]); + }); + + it("refuses an admin mapping made ambiguous by binding another admin role", () => { + const ambiguous = { + ...catalog, + roles: [...catalog.roles, role("trusted-admin", ["tg:trusted"], "admin", ["admin"])], + }; + expect(() => + planPeopleCommand(ambiguous, [], command([{ kind: "admin", active: true }])), + ).toThrow(/exactly one/); + }); +}); diff --git a/src/auth/people-binding.ts b/src/auth/people-binding.ts index 661ed79..24b268a 100644 --- a/src/auth/people-binding.ts +++ b/src/auth/people-binding.ts @@ -12,27 +12,30 @@ import { /** * What holding a role means in the People register of the admin dashboard: * - `admin`: an admin of the Telegram course groups; + * - `capo`: a Capo Admin, who coordinates admins; * - `team::member` / `team::lead`: a member or the Lead of a volunteer team. * - * Only a bound role can be handed out or taken back by the backend on someone's behalf, and - * only Master Admin binds a role, because binding one delegates its membership to People. + * Only a bound role can be handed out or taken back by the backend on someone's behalf. + * Binding one delegates its membership to People, so it needs `idp:people:bind`. */ export type PeopleBinding = | { kind: "admin" } + | { kind: "capo" } | { kind: "team"; team: string; role: "member" | "lead" }; /** Mirrors the `role_people_binding_check` constraint. */ -export const PEOPLE_BINDING_PATTERN = /^(admin|team:[a-z0-9-]{1,32}:(member|lead))$/; +export const PEOPLE_BINDING_PATTERN = /^(admin|capo|team:[a-z0-9-]{1,32}:(member|lead))$/; export function parsePeopleBinding(value: string | null | undefined): PeopleBinding | null { if (!value || !PEOPLE_BINDING_PATTERN.test(value)) return null; - if (value === "admin") return { kind: "admin" }; + if (value === "admin" || value === "capo") return { kind: value }; const [, team, role] = value.split(":"); return { kind: "team", team: team!, role: role as "member" | "lead" }; } export function describePeopleBinding(binding: PeopleBinding) { if (binding.kind === "admin") return "Admin of the Telegram groups"; + if (binding.kind === "capo") return "Capo Admin"; return `${binding.role === "lead" ? "Lead" : "Member"} of team ${binding.team}`; } @@ -62,21 +65,65 @@ export type PeopleRoleChange = { action: "assign" | "unassign"; }; +/** What a delegation rule sees: the change, and one binding the changed role carries. */ +export type PeopleRuleContext = PeopleRoleChange & { + binding: B; + /** Whether the actor holds `admin:access` and `permission`. */ + general: (permission: string) => boolean; + /** The bindings of the roles the actor holds, including through a parent role. */ + held: PeopleBinding[]; +}; + +type PeopleRule = (context: PeopleRuleContext) => string | null; + +const NEEDS_WRITE = "Changing this role needs people:write and admin:access."; +const NEEDS_WRITE_OR_LEAD = + "Changing this role needs people:write and admin:access, or leading the team."; +const NEEDS_APPOINT = "Appointing or ending a Lead needs people:appoint and admin:access."; +const NEEDS_APPOINT_CAPO = + "Appointing or ending a Capo Admin needs people:appoint and admin:access."; + +/** + * Who may give or take back a role, one rule per binding kind. A rule answers why not, or + * null when the actor may. Self-grants, `idp:*` rights and bounded delegation are checked + * around the rules, for every kind alike. + * + * A future rule such as "a Capo Admin may add or remove the admins under them" belongs in + * `admin`, as another way to be allowed, once Auth can verify which admins are under whom. + */ +export const PEOPLE_RULES: { + [K in PeopleBinding["kind"]]: PeopleRule>; +} = { + admin: ({ general }) => (general(PEOPLE_WRITE_PERMISSION) ? null : NEEDS_WRITE), + capo: ({ general }) => (general(PEOPLE_APPOINT_PERMISSION) ? null : NEEDS_APPOINT_CAPO), + team: ({ binding, general, held }) => { + if (binding.role === "lead") return general(PEOPLE_APPOINT_PERMISSION) ? null : NEEDS_APPOINT; + // A team's Lead may also add and remove that team's members. + const leads = held.some( + (entry) => entry.kind === "team" && entry.team === binding.team && entry.role === "lead", + ); + return general(PEOPLE_WRITE_PERMISSION) || leads ? null : NEEDS_WRITE_OR_LEAD; + }, +}; + +function ruleProblem(context: PeopleRuleContext): string | null { + const rule = PEOPLE_RULES[context.binding.kind] as PeopleRule; + return rule(context); +} + /** * Whether the backend may change who holds `role` on the actor's behalf; a reason when not. * * - Only bound roles, and only while they carry no `idp:*` permission. * - Nobody gives themselves a role. Giving one up is always allowed: it only removes access. - * - Admin and team member roles need `people:write`; a team's Lead may also add and remove - * that team's members. Lead roles need `people:appoint`. Both need `admin:access`, except - * for a Lead acting on their own team. + * - The rule of the role's binding kind, and of every binding it inherits: a member role + * wrapping a Lead role needs what the Lead role needs. See `PEOPLE_RULES`. * - Bounded delegation, as in the dashboard: unless Master Admin, the actor must already hold * every permission the role grants, so nobody hands out more than they have. */ export function peopleRoleChangeProblem(change: PeopleRoleChange): string | null { const { catalog, actorId, actor, targetId, role, action } = change; - const binding = parsePeopleBinding(role.peopleBinding); - if (!binding) return "This role is not managed from People."; + if (!parsePeopleBinding(role.peopleBinding)) return "This role is not managed from People."; const problem = peopleBindingProblem(catalog, role); if (problem) return problem; if (actorId === targetId) @@ -84,30 +131,21 @@ export function peopleRoleChangeProblem(change: PeopleRoleChange): string | null const holds = (key: string) => actor.permissions.includes(key); const general = (key: string) => holds(ADMIN_ACCESS_PERMISSION) && holds(key); - const leadsTeam = (team: string) => - actor.roles.some((key) => { - const held = parsePeopleBinding( + const bindingsOf = (keys: string[]) => + keys.flatMap((key) => { + const binding = parsePeopleBinding( catalog.roles.find((entry) => entry.key === key)?.peopleBinding, ); - return held?.kind === "team" && held.team === team && held.role === "lead"; + return binding ? [binding] : []; }); + const held = bindingsOf(actor.roles); // A role grants organizational authority through every inherited binding as well as // through permissions. A member wrapper around a Lead must require appointment rights. - for (const key of expandRoleKeys(catalog, [role.key])) { - const inherited = parsePeopleBinding( - catalog.roles.find((entry) => entry.key === key)?.peopleBinding, - ); - if (!inherited) continue; - const allowed = - inherited.kind === "admin" - ? general(PEOPLE_WRITE_PERMISSION) - : inherited.role === "lead" - ? general(PEOPLE_APPOINT_PERMISSION) - : general(PEOPLE_WRITE_PERMISSION) || leadsTeam(inherited.team); - if (!allowed) - return inherited.kind === "team" && inherited.role === "lead" - ? "Appointing or ending a Lead needs people:appoint and admin:access." - : "Changing this role needs people:write and admin:access, or leading the team."; + // The role's own rule first, so a refusal names what the role itself needs. + const inherited = expandRoleKeys(catalog, [role.key]).filter((key) => key !== role.key); + for (const binding of bindingsOf([role.key, ...inherited])) { + const refused = ruleProblem({ ...change, binding, general, held }); + if (refused) return refused; } if (actor.roles.includes(MASTER_ADMIN_ROLE_KEY)) return null; diff --git a/src/auth/people-role-command.ts b/src/auth/people-role-command.ts index 1c13956..4cf806f 100644 --- a/src/auth/people-role-command.ts +++ b/src/auth/people-role-command.ts @@ -2,6 +2,12 @@ import { z } from "zod"; import { parsePeopleBinding, peopleBindingProblem } from "./people-binding"; import { expandRoleKeys, type RbacCatalog } from "./rbac"; +/** + * One People command from the backend. Each change names a desired state for one concept. + * Unknown fields are stripped, so a later optional field (for example the scope a Capo Admin + * acts in) can be sent before every Auth deployment reads it: an Auth that ignores it only + * applies the stricter rules it already has. + */ export const peopleCommandSchema = z .object({ id: z.uuid(), @@ -12,6 +18,7 @@ export const peopleCommandSchema = z .array( z.discriminatedUnion("kind", [ z.object({ kind: z.literal("admin"), active: z.boolean() }), + z.object({ kind: z.literal("capo"), active: z.boolean() }), z.object({ kind: z.literal("team"), team: z.string().regex(/^[a-z0-9-]{1,32}$/), @@ -23,7 +30,7 @@ export const peopleCommandSchema = z .max(64), }) .superRefine((command, ctx) => { - const keys = command.changes.map((c) => (c.kind === "admin" ? "admin" : `team:${c.team}`)); + const keys = command.changes.map((c) => (c.kind === "team" ? `team:${c.team}` : c.kind)); if (new Set(keys).size !== keys.length) ctx.addIssue({ code: "custom", message: "Each concept may appear only once" }); }); @@ -74,10 +81,10 @@ export function planPeopleCommand( next.add(keys[0]!); }; for (const change of command.changes) { - if (change.kind === "admin") { - const keys = keysFor("admin"); + if (change.kind !== "team") { + const keys = keysFor(change.kind); if (!keys.length) continue; // An unbound concept remains local to People. - if (change.active) give(keys, "admin"); + if (change.active) give(keys, change.kind); else remove(keys); } else { const members = keysFor(`team:${change.team}:member`); @@ -94,10 +101,10 @@ export function planPeopleCommand( for (const change of command.changes) { const has = (binding: string) => bound.some((r) => r.peopleBinding === binding && result.held.includes(r.key)); - if (change.kind === "admin") { - if (keysFor("admin").length && has("admin") !== change.active) + if (change.kind !== "team") { + if (keysFor(change.kind).length && has(change.kind) !== change.active) throw new PeopleCommandConflict( - "The admin role is inherited. Change its granting role in Auth.", + `The ${change.kind} role is inherited. Change its granting role in Auth.`, ); } else { if (!bound.some((r) => r.peopleBinding?.startsWith(`team:${change.team}:`))) continue; diff --git a/src/auth/people-roles.integration.test.ts b/src/auth/people-roles.integration.test.ts index 14d048e..19731bf 100644 --- a/src/auth/people-roles.integration.test.ts +++ b/src/auth/people-roles.integration.test.ts @@ -36,6 +36,8 @@ import { assignRole, saveRole, setRolePeopleBinding } from "./rbac-store"; const root = "people-root"; const hr = "people-hr"; const target = "people-target"; +const binder = "people-binder"; +const users = [root, hr, target, binder]; const team = `t${randomUUID().slice(0, 8)}`; describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( @@ -61,12 +63,13 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( beforeAll(async () => { await pool.query( `INSERT INTO "user" (id, name, email) SELECT id, id, id || '@identity.invalid' FROM unnest($1::text[]) AS id`, - [[root, hr, target]], + [users], ); await pool.query( - `INSERT INTO permission (id, key, name) VALUES ($1, 'people:write', 'people:write'), ($2, 'admin:access', 'admin:access') + `INSERT INTO permission (id, key, name) VALUES ($1, 'people:write', 'people:write'), ($2, 'admin:access', 'admin:access'), + ($3, 'people:appoint', 'people:appoint'), ($4, $5, $5) ON CONFLICT DO NOTHING`, - [randomUUID(), randomUUID()], + [randomUUID(), randomUUID(), randomUUID(), randomUUID(), `${team}:perk`], ); const role = async (key: string, permissions: string[], parents: string[] = []) => { const saved = await saveRole(root, { @@ -83,15 +86,22 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( await role(`${team}-member`, []); await role(`${team}-lead`, [], [`${team}-member`]); await role(`${team}-admin`, []); + await role(`${team}-capo`, [], [`${team}-admin`]); + await role(`${team}-binder`, ["idp:people:bind", "admin:access"]); + await role(`${team}-perk`, [`${team}:perk`]); + await role(`${team}-plain`, ["admin:access"]); await setRolePeopleBinding(root, roles[`${team}-admin`]!, "admin"); + await setRolePeopleBinding(root, roles[`${team}-capo`]!, "capo"); await setRolePeopleBinding(root, roles[`${team}-member`]!, `team:${team}:member`); await setRolePeopleBinding(root, roles[`${team}-lead`]!, `team:${team}:lead`); await assignRole(root, roles[`${team}-hr`]!, hr); + await assignRole(root, roles[`${team}-binder`]!, binder); }); afterAll(async () => { - await pool.query(`DELETE FROM "user" WHERE id = ANY($1)`, [[root, hr, target]]); + await pool.query(`DELETE FROM "user" WHERE id = ANY($1)`, [users]); await pool.query(`DELETE FROM role WHERE key LIKE $1`, [`${team}-%`]); + await pool.query(`DELETE FROM permission WHERE key = $1`, [`${team}:perk`]); await pool.end(); await db.$client.end(); }); @@ -145,5 +155,72 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( assigned: [`${team}-member`], }); }); + + it("applies an accepted invite with the assigner as actor, never the invitee", async () => { + const invite = command([{ kind: "admin", active: true }]); + await expect(changePeopleRoles({ ...invite, actor: target })).rejects.toMatchObject({ + status: 403, + message: expect.stringContaining("themselves"), + }); + expect(await changePeopleRoles(invite)).toMatchObject({ + held: expect.arrayContaining([`${team}-admin`]), + }); + const [audit] = ( + await pool.query<{ actor_id: string; after: { via: string; userId: string } }>( + `SELECT actor_id, after FROM rbac_audit_event WHERE operation = 'role.assign' AND after->>'commandId' = $1`, + [invite.id], + ) + ).rows; + expect(audit).toMatchObject({ actor_id: hr, after: { via: "people", userId: target } }); + }); + + it("needs people:appoint to appoint a Capo Admin, who then holds admin through Capo", async () => { + const appoint = command([{ kind: "capo", active: true }]); + await expect(changePeopleRoles(appoint)).rejects.toMatchObject({ + status: 403, + message: expect.stringContaining("people:appoint"), + }); + expect(await changePeopleRoles({ ...appoint, actor: root })).toMatchObject({ + held: expect.arrayContaining([`${team}-admin`, `${team}-capo`]), + }); + // Ending the admin role alone would leave it in place through Capo. + await expect( + changePeopleRoles({ ...command([{ kind: "admin", active: false }]), actor: root }), + ).rejects.toMatchObject({ status: 409 }); + }); + + it("lists only holders of bound roles, with when they got each one", async () => { + const directory = await readPeopleDirectory(); + expect(directory.roles.map((entry) => entry.binding)).toEqual( + expect.arrayContaining(["admin", "capo", `team:${team}:member`, `team:${team}:lead`]), + ); + const today = new Date().toISOString().slice(0, 10); + const person = directory.users.find((entry) => entry.sub === target)!; + expect(person).not.toHaveProperty("azureId"); + expect(person.since).toMatchObject({ + [`${team}-admin`]: today, + [`${team}-capo`]: today, + [`${team}-member`]: today, + }); + expect(directory.users.some((entry) => entry.sub === binder)).toBe(false); + }); + + it("lets idp:people:bind holders bind roles, only within the permissions they hold", async () => { + await expect( + setRolePeopleBinding(hr, roles[`${team}-plain`]!, `team:${team}x:member`), + ).rejects.toMatchObject({ status: 403 }); + await expect( + setRolePeopleBinding(binder, roles[`${team}-perk`]!, `team:${team}x:member`), + ).rejects.toMatchObject({ status: 403 }); + expect( + await setRolePeopleBinding(binder, roles[`${team}-plain`]!, `team:${team}x:member`), + ).toMatchObject({ peopleBinding: `team:${team}x:member` }); + await expect( + setRolePeopleBinding(binder, roles[`${team}-plain`]!, "capo:x"), + ).rejects.toMatchObject({ status: 400 }); + expect(await setRolePeopleBinding(binder, roles[`${team}-plain`]!, null)).toMatchObject({ + peopleBinding: null, + }); + }); }, ); diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index 830bb8b..f959e52 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -6,17 +6,14 @@ import { account, identityEvidence, rbacAuditEvent, role, user, userRole } from import { contactEmails } from "./contact-email"; import { logAuthorizationDenial } from "./denial-log"; import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; -import { - parsePeopleBinding, - peopleBindingProblem, - peopleRoleChangeProblem, -} from "./people-binding"; +import { peopleRoleChangeProblem } from "./people-binding"; import { type PeopleCommand, type PeopleCommandResult, PeopleCommandConflict, - planPeopleCommand, + peopleBoundRoles, peopleCommandResult, + planPeopleCommand, } from "./people-role-command"; import { expandRoleKeys, resolveAccess } from "./rbac"; import { RbacError, assignedRoleKeys, auditSnapshot, readCatalog } from "./rbac-store"; @@ -24,23 +21,23 @@ import { RbacError, assignedRoleKeys, auditSnapshot, readCatalog } from "./rbac- export type PeopleDirectory = { /** Every role People manages, with what holding it means. */ roles: { key: string; name: string; binding: string }[]; - /** - * Everyone holding at least one of those roles, by hand or through a parent role, and - * everyone with a PoliNetwork Microsoft 365 account, whose `roles` may be empty. - */ + /** Everyone holding at least one of those roles, by hand or through a parent role. */ users: { sub: string; name: string; /** Their PoliNetwork address when they linked it, otherwise their sign-in address. */ email: string | null; - /** Their PoliNetwork Microsoft 365 object ID, when they linked that account. */ - azureId: string | null; telegramId: string | null; telegramUsername: string | null; /** The bound roles they hold, sorted, including through a parent role. */ roles: string[]; /** Those of `roles` given to them directly: the only ones that can be taken back. */ assigned: string[]; + /** + * When they were first given each of `roles` (`YYYY-MM-DD`, UTC): the earliest of their + * current assignments that confers it, directly or through a parent role. + */ + since: Record; }[]; }; @@ -53,9 +50,7 @@ export async function readPeopleDirectory(): Promise { return db.transaction( async (transaction) => { const catalog = await readCatalog(transaction); - const bound = catalog.roles.filter( - (entry) => parsePeopleBinding(entry.peopleBinding) && !peopleBindingProblem(catalog, entry), - ); + const bound = peopleBoundRoles(catalog); const boundKeys = new Set(bound.map((entry) => entry.key)); const roles = bound.map((entry) => ({ key: entry.key, @@ -65,36 +60,26 @@ export async function readPeopleDirectory(): Promise { if (!bound.length) return { roles, users: [] }; const assignments = await transaction - .select({ userId: userRole.userId, roleKey: role.key }) + .select({ userId: userRole.userId, roleKey: role.key, assignedAt: userRole.assignedAt }) .from(userRole) .innerJoin(role, eq(userRole.roleId, role.id)) .where(eq(role.managed, false)); const assigned = new Map(); - for (const row of assignments) + const since = new Map>(); + for (const row of assignments) { assigned.set(row.userId, [...(assigned.get(row.userId) ?? []), row.roleKey]); + const day = row.assignedAt.toISOString().slice(0, 10); + const dates = since.get(row.userId) ?? {}; + for (const key of expandRoleKeys(catalog, [row.roleKey])) + if (boundKeys.has(key) && !(dates[key] && dates[key] <= day)) dates[key] = day; + since.set(row.userId, dates); + } const held = new Map(); for (const [userId, keys] of assigned) { const roles = expandRoleKeys(catalog, keys).filter((key) => boundKeys.has(key)); if (roles.length) held.set(userId, roles); } - // Also every PoliNetwork Microsoft 365 account, bound role or not: People matches its - // Microsoft 365 members to linked accounts by that stable ID instead of an address. - const entra = await transaction - .select({ userId: account.userId, azureId: identityEvidence.externalId }) - .from(account) - .innerJoin( - identityEvidence, - and( - eq(account.issuer, identityEvidence.issuer), - eq(account.accountId, identityEvidence.subject), - eq(account.providerId, identityEvidence.providerId), - ), - ) - .where(eq(account.providerId, "pn-entra")); - const azureByUser = new Map( - entra.flatMap((row) => (row.azureId ? [[row.userId, row.azureId] as const] : [])), - ); - const ids = [...new Set([...held.keys(), ...azureByUser.keys()])]; + const ids = [...held.keys()]; if (!ids.length) return { roles, users: [] }; const people = await transaction .select({ id: user.id, name: user.name, email: user.email }) @@ -126,11 +111,14 @@ export async function readPeopleDirectory(): Promise { sub: person.id, name: person.name, email: emails.get(person.id) ?? null, - azureId: azureByUser.get(person.id) ?? null, telegramId: telegramByUser.get(person.id)?.telegramId ?? null, telegramUsername: telegramByUser.get(person.id)?.telegramUsername ?? null, - roles: held.get(person.id) ?? [], - assigned: (assigned.get(person.id) ?? []).filter((key) => boundKeys.has(key)).sort(), + roles: held.get(person.id)!, + assigned: assigned + .get(person.id)! + .filter((key) => boundKeys.has(key)) + .sort(), + since: since.get(person.id)!, })), }; }, diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts index 37dd56e..97ccbcf 100644 --- a/src/auth/rbac-store.ts +++ b/src/auth/rbac-store.ts @@ -184,7 +184,7 @@ export async function withAuthorizedRbacWrite( actorId: string, required: Extract< ManagedPermissionKey, - `${string}:write` | `${string}:assign` | `${string}:delete` + `${string}:write` | `${string}:assign` | `${string}:delete` | `${string}:bind` >, change: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise, ): Promise { @@ -239,9 +239,10 @@ export async function auditSnapshot( /** Editing role definitions and handing roles out are delegated separately. */ function requiredForOperation( operation: string, -): "idp:permissions:write" | "idp:roles:write" | "idp:roles:assign" { +): "idp:permissions:write" | "idp:roles:write" | "idp:roles:assign" | "idp:people:bind" { if (operation.startsWith("permission.")) return "idp:permissions:write"; if (operation === "role.assign" || operation === "role.unassign") return "idp:roles:assign"; + if (operation === "role.people") return "idp:people:bind"; return "idp:roles:write"; } @@ -427,23 +428,21 @@ export async function saveRole( /** * Binds a role to a People concept, or unbinds it with `null`. Binding hands the role's - * membership to People writers (through the backend), so only Master Admin decides it. + * membership to People writers (through the backend), so it needs `idp:people:bind`, and, + * like editing a role, only a Master Admin may bind or unbind one granting permissions they + * don't hold (`mayDelegateMutation`). */ export async function setRolePeopleBinding( actorId: string, roleId: string, binding: string | null, ) { - return withRbacWriteLock(actorId, "role.people", roleId, async (transaction, catalog, access) => { - if (!access.roles.includes(MASTER_ADMIN_ROLE_KEY)) { - logAuthorizationDenial(actorId, "rbac-store", [MASTER_ADMIN_ROLE_KEY]); - throw new RbacError(403, "Only Master Admin can choose which roles People manages."); - } + return withRbacWriteLock(actorId, "role.people", roleId, async (transaction, catalog) => { const current = requireRole(catalog, roleId); if (binding !== null) { if (!PEOPLE_BINDING_PATTERN.test(binding)) - throw new RbacError(400, "Choose admin, or a team key with member or lead.", { - peopleBinding: "Use admin, team::member or team::lead.", + throw new RbacError(400, "Choose admin, capo, or a team key with member or lead.", { + peopleBinding: "Use admin, capo, team::member or team::lead.", }); const problem = peopleBindingProblem(catalog, current); if (problem) throw new RbacError(400, problem); diff --git a/src/auth/rbac.functions.ts b/src/auth/rbac.functions.ts index 6678f28..9d78856 100644 --- a/src/auth/rbac.functions.ts +++ b/src/auth/rbac.functions.ts @@ -34,9 +34,9 @@ export const saveRoleFn = createServerFn({ method: "POST" }) .validator(validate(z.object({ roleId: z.string().min(1).optional(), draft: roleDraftSchema }))) .handler(({ context, data }) => saveRole(context.session.userId, data.draft, data.roleId)); -/** Binds a role to People, or unbinds it with `null`. The store allows Master Admin only. */ +/** Binds a role to People, or unbinds it with `null`. */ export const setRolePeopleBindingFn = createServerFn({ method: "POST" }) - .middleware([permissionMiddleware("idp:roles:write")]) + .middleware([permissionMiddleware("idp:people:bind")]) .validator( validate(z.object({ roleId: z.string().min(1), binding: z.string().max(64).nullable() })), ) diff --git a/src/auth/rbac.test.ts b/src/auth/rbac.test.ts index 597856a..91bdc47 100644 --- a/src/auth/rbac.test.ts +++ b/src/auth/rbac.test.ts @@ -283,6 +283,7 @@ describe("permissions the identity provider defines itself", () => { "idp:roles:read", "idp:roles:write", "idp:roles:assign", + "idp:people:bind", "idp:applications:read", "idp:applications:write", "idp:mcp", diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts index 337d8f1..37ff0d8 100644 --- a/src/auth/rbac.ts +++ b/src/auth/rbac.ts @@ -134,6 +134,13 @@ export const MANAGED_PERMISSIONS = [ "Give roles to people and take them away. Changing what a role grants is a separate permission.", implies: ["idp:roles:read", "idp:people:read"], }, + { + key: "idp:people:bind", + name: "Bind roles to People", + description: + "Choose which roles the admin dashboard's People register manages, and what holding each one means there.", + implies: ["idp:roles:read"], + }, { key: "idp:applications:read", name: "View applications", @@ -178,6 +185,8 @@ const INTRINSIC_IMPLICATIONS = new Map( ["idp:permissions:write", ["idp:permissions:read"]], ["idp:roles:write", ["idp:roles:read"]], ["idp:roles:assign", ["idp:roles:read"]], + // Binding a role means seeing what it grants. + ["idp:people:bind", ["idp:roles:read"]], // Deleting someone without seeing who they are makes no sense either. ["idp:users:delete", ["idp:users:read"]], ]); diff --git a/src/components/rbac/people-binding-card.tsx b/src/components/rbac/people-binding-card.tsx index 9b19809..579878a 100644 --- a/src/components/rbac/people-binding-card.tsx +++ b/src/components/rbac/people-binding-card.tsx @@ -17,18 +17,19 @@ import { } from "@/components/ui/select"; import { errorMessage } from "@/lib/action-error"; -type Kind = "none" | "admin" | "member" | "lead"; +type Kind = "none" | "admin" | "capo" | "member" | "lead"; const KINDS: { value: Kind; label: string }[] = [ { value: "none", label: "Not managed from People" }, { value: "admin", label: "Admin of the Telegram groups" }, + { value: "capo", label: "Capo Admin" }, { value: "member", label: "Team member" }, { value: "lead", label: "Team Lead" }, ]; function bindingOf(kind: Kind, team: string) { if (kind === "none") return null; - if (kind === "admin") return "admin"; + if (kind === "admin" || kind === "capo") return kind; return `team:${team.trim().toLowerCase()}:${kind}`; } @@ -42,6 +43,7 @@ export function PeopleBindingCard({ problem, }: { role: RoleSummary; + /** Whether the viewer holds `idp:people:bind` and every permission the role grants. */ canEdit: boolean; /** Why the role can't be bound, if it can't. */ problem: string | null; @@ -84,8 +86,8 @@ export function PeopleBindingCard({ {!canEdit ? (

{KINDS.find((entry) => entry.value === kind)?.label} - {(kind === "member" || kind === "lead") && ` of team ${team}`}. Only Master Admin can - change this. + {(kind === "member" || kind === "lead") && ` of team ${team}`}. Changing this needs the + Bind roles to People permission and every permission this role grants.

) : problem && !current ? (

{problem}

diff --git a/src/db/rbac.ts b/src/db/rbac.ts index fc1190f..9586b2d 100644 --- a/src/db/rbac.ts +++ b/src/db/rbac.ts @@ -71,8 +71,8 @@ export const role = pgTable( managed: boolean().default(false).notNull(), sourceState: text("source_state"), /** - * What holding this role means in the People register (`admin`, `team::member` or - * `team::lead`). Only a bound role can be handed out or taken back by the backend on + * What holding this role means in the People register (`admin`, `capo`, + * `team::member` or `team::lead`). Only a bound role can be handed out or taken back by the backend on * behalf of a dashboard user; see `src/auth/people-roles.ts`. */ peopleBinding: text("people_binding"), @@ -83,7 +83,7 @@ export const role = pgTable( uniqueIndex("role_key_uidx").on(table.key), check( "role_people_binding_check", - sql`${table.peopleBinding} is null or (not ${table.managed} and ${table.peopleBinding} ~ '^(admin|team:[a-z0-9-]{1,32}:(member|lead))$')`, + sql`${table.peopleBinding} is null or (not ${table.managed} and ${table.peopleBinding} ~ '^(admin|capo|team:[a-z0-9-]{1,32}:(member|lead))$')`, ), ], ); diff --git a/src/routes/access/roles/$roleId.tsx b/src/routes/access/roles/$roleId.tsx index a57640b..b3c67d2 100644 --- a/src/routes/access/roles/$roleId.tsx +++ b/src/routes/access/roles/$roleId.tsx @@ -60,6 +60,9 @@ function RoleDetail() { !!role && !role.managed && canGrantRole(access, catalog, role.key); + // Binding hands the role's membership to People, so it is bounded the same way. + const canBind = + access.can("idp:people:bind") && !!role && canGrantRole(access, catalog, role.key); async function save(draft: RoleDraft) { setBusy(true); @@ -170,7 +173,7 @@ function RoleDetail() { )} diff --git a/src/routes/api/internal/people-roles.ts b/src/routes/api/internal/people-roles.ts index 345164a..e567c8f 100644 --- a/src/routes/api/internal/people-roles.ts +++ b/src/routes/api/internal/people-roles.ts @@ -20,7 +20,7 @@ async function backendClient(request: Request, scope: "idp:access:read" | "idp:p } /** - * The roles Master Admin bound to People and who holds them (GET), and changes to them made + * The roles bound to People and who holds them (GET), and changes to them made * by the backend on behalf of a dashboard user (POST). See `src/auth/people-binding.ts` for * what the acting person needs. */ From 8c4c9f8ce82958d7601cdd5f219b8e46e6a175d7 Mon Sep 17 00:00:00 2001 From: Bianca Roberta Ianosel Date: Fri, 30 Oct 2026 16:41:16 +0100 Subject: [PATCH 6/6] feat(people): describe each bound role's permissions and inherited bindings (#80) --- README.md | 4 ++- src/auth/people-binding.test.ts | 19 ++++++++++- src/auth/people-role-command.ts | 39 ++++++++++++++++++++--- src/auth/people-roles.integration.test.ts | 4 +++ src/auth/people-roles.ts | 16 ++++------ 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 85e8456..eca30f8 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ Future consumers should use authorization code with PKCE, validate token signatu The admin dashboard's People register gives people predetermined roles: those bound, on the role's page (People register card), to what holding them means in People. A role's holders are admins of the Telegram groups (`admin`), Capo Admins (`capo`), or members or the Lead of a team (`team::member`, `team::lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Binding a role needs `idp:people:bind`, and, as when editing a role, only Master Admin can bind or unbind a role granting permissions they don't hold. Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. Bind only one role to each meaning: `trusted-admin` inherits `admin`, so binding it as `admin` too makes giving `admin` ambiguous (409). -`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles and everyone holding them by hand or through a parent role, with their name, directory email, Telegram ID and username, which of those roles they were given directly, and since when they hold each one (`since`, `YYYY-MM-DD`: the earliest of their current assignments conferring it). Unlike the access snapshot it includes roles that grant no permission. People without a login account are the backend's own: they never appear here. +`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, each with every permission it grants (`permissions`, through parent roles too) and its own binding followed by those it inherits (`bindings`), and everyone holding them by hand or through a parent role, with their name, directory email, Telegram ID and username, which of those roles they were given directly, and since when they hold each one (`since`, `YYYY-MM-DD`: the earliest of their current assignments conferring it). Unlike the access snapshot it includes roles that grant no permission. People without a login account are the backend's own: they never appear here. `POST /api/internal/people-roles` (backend client, `idp:people:roles`) applies one People command `{ id, actor, sub, reason, changes }` on behalf of the dashboard user the backend authenticated. `sub` is always a login account. Each change names a desired state: `{ kind: "admin" | "capo", active }` or `{ kind: "team", team, role: "member" | "lead" | null }`. This IdP plans the concrete assignments against its own role graph and applies all of them or none: a concept with no bound role stays People's own and is skipped; it refuses with 409 when the mapping is ambiguous (not exactly one bound role to give), or when the requested state would still be held through another role (ending `admin` for a Capo Admin, say), rather than reporting a change that did not happen. @@ -361,6 +361,8 @@ The backend only names the actor; this IdP resolves the actor's own access and r - unless Master Admin, the actor holds every permission the role grants. +The backend applies the same rules to assignments it keeps for records that aren't linked yet, which this IdP only sees when the record is linked: that is what each bound role's `permissions` and `bindings` are for. + The response is the person's bound roles after the command (`held`, `assigned`) and the bound role list. Each assignment is audited as `role.assign`/`role.unassign` with `via: "people"`, the reason and the command ID, and the command itself as `people.roles` under `people-command:`: repeating an ID with the same command, for example after a lost response, applies nothing again and returns the current roles, and repeating it with a different command is refused with 409. #### Deploying People roles diff --git a/src/auth/people-binding.test.ts b/src/auth/people-binding.test.ts index 12c1a00..42bebcd 100644 --- a/src/auth/people-binding.test.ts +++ b/src/auth/people-binding.test.ts @@ -14,7 +14,7 @@ import { MASTER_ADMIN_ROLE_KEY, resolveAccess, } from "./rbac"; -import { planPeopleCommand } from "./people-role-command"; +import { describePeopleBoundRoles, planPeopleCommand } from "./people-role-command"; function permission(key: string, managed = false): PermissionSummary { return { @@ -313,3 +313,20 @@ describe("planPeopleCommand with Capo Admins", () => { ).toThrow(/exactly one/); }); }); + +it("describes each bound role with its effective permissions and inherited bindings", () => { + const described = describePeopleBoundRoles(catalog); + expect(described.find((entry) => entry.key === "capo-admin")).toEqual({ + key: "capo-admin", + name: "capo-admin", + binding: "capo", + permissions: ["people:self", "tg:trusted"], + bindings: ["capo", "admin"], + }); + expect(described.find((entry) => entry.key === "hr-lead")).toMatchObject({ + permissions: ["admin:access", "people:read", "people:self", "people:write"], + bindings: ["team:hr:lead", "team:hr:member"], + }); + // Roles People can't manage are left out, as everywhere else. + expect(described.map((entry) => entry.key)).not.toContain("sneaky"); +}); diff --git a/src/auth/people-role-command.ts b/src/auth/people-role-command.ts index 4cf806f..a71b05a 100644 --- a/src/auth/people-role-command.ts +++ b/src/auth/people-role-command.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { parsePeopleBinding, peopleBindingProblem } from "./people-binding"; -import { expandRoleKeys, type RbacCatalog } from "./rbac"; +import { effectiveRolePermissions, expandRoleKeys, type RbacCatalog } from "./rbac"; /** * One People command from the backend. Each change names a desired state for one concept. @@ -35,8 +35,22 @@ export const peopleCommandSchema = z ctx.addIssue({ code: "custom", message: "Each concept may appear only once" }); }); export type PeopleCommand = z.infer; +/** + * A role bound to People, as the backend sees it. `permissions` and `bindings` let it apply + * `peopleRoleChangeProblem`'s rules itself to assignments it keeps before a record is linked, + * which this identity provider only sees at link time. + */ +export type PeopleBoundRole = { + key: string; + name: string; + binding: string; + /** Every permission the role grants, including through parent roles, sorted. */ + permissions: string[]; + /** Its own binding, then those of the roles it inherits. */ + bindings: string[]; +}; export type PeopleCommandResult = { - roles: { key: string; name: string; binding: string }[]; + roles: PeopleBoundRole[]; held: string[]; assigned: string[]; }; @@ -49,11 +63,28 @@ export function peopleBoundRoles(catalog: RbacCatalog) { ); } +/** The bound roles, with what `peopleRoleChangeProblem` checks for each of them. */ +export function describePeopleBoundRoles(catalog: RbacCatalog): PeopleBoundRole[] { + return peopleBoundRoles(catalog).map((r) => { + const inherited = expandRoleKeys(catalog, [r.key]).flatMap((key) => { + const binding = catalog.roles.find((entry) => entry.key === key)?.peopleBinding; + return key !== r.key && parsePeopleBinding(binding) ? [binding!] : []; + }); + return { + key: r.key, + name: r.name, + binding: r.peopleBinding!, + permissions: effectiveRolePermissions(catalog, r.key), + bindings: [r.peopleBinding!, ...inherited], + }; + }); +} + export function peopleCommandResult(catalog: RbacCatalog, assigned: string[]): PeopleCommandResult { - const roles = peopleBoundRoles(catalog); + const roles = describePeopleBoundRoles(catalog); const keys = new Set(roles.map((r) => r.key)); return { - roles: roles.map((r) => ({ key: r.key, name: r.name, binding: r.peopleBinding! })), + roles, held: expandRoleKeys(catalog, assigned).filter((key) => keys.has(key)), assigned: assigned.filter((key) => keys.has(key)).sort(), }; diff --git a/src/auth/people-roles.integration.test.ts b/src/auth/people-roles.integration.test.ts index 19731bf..2aec31c 100644 --- a/src/auth/people-roles.integration.test.ts +++ b/src/auth/people-roles.integration.test.ts @@ -194,6 +194,10 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( expect(directory.roles.map((entry) => entry.binding)).toEqual( expect.arrayContaining(["admin", "capo", `team:${team}:member`, `team:${team}:lead`]), ); + expect(directory.roles.find((entry) => entry.key === `${team}-capo`)).toMatchObject({ + permissions: [], + bindings: ["capo", "admin"], + }); const today = new Date().toISOString().slice(0, 10); const person = directory.users.find((entry) => entry.sub === target)!; expect(person).not.toHaveProperty("azureId"); diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index f959e52..4c52535 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -8,10 +8,11 @@ import { logAuthorizationDenial } from "./denial-log"; import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; import { peopleRoleChangeProblem } from "./people-binding"; import { + type PeopleBoundRole, type PeopleCommand, type PeopleCommandResult, PeopleCommandConflict, - peopleBoundRoles, + describePeopleBoundRoles, peopleCommandResult, planPeopleCommand, } from "./people-role-command"; @@ -20,7 +21,7 @@ import { RbacError, assignedRoleKeys, auditSnapshot, readCatalog } from "./rbac- export type PeopleDirectory = { /** Every role People manages, with what holding it means. */ - roles: { key: string; name: string; binding: string }[]; + roles: PeopleBoundRole[]; /** Everyone holding at least one of those roles, by hand or through a parent role. */ users: { sub: string; @@ -50,14 +51,9 @@ export async function readPeopleDirectory(): Promise { return db.transaction( async (transaction) => { const catalog = await readCatalog(transaction); - const bound = peopleBoundRoles(catalog); - const boundKeys = new Set(bound.map((entry) => entry.key)); - const roles = bound.map((entry) => ({ - key: entry.key, - name: entry.name, - binding: entry.peopleBinding!, - })); - if (!bound.length) return { roles, users: [] }; + const roles = describePeopleBoundRoles(catalog); + const boundKeys = new Set(roles.map((entry) => entry.key)); + if (!roles.length) return { roles, users: [] }; const assignments = await transaction .select({ userId: userRole.userId, roleKey: role.key, assignedAt: userRole.assignedAt })