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/9] 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/9] 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/9] 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/9] 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 b57a8582ae96dadd9418e04c341f3dd6076b237f Mon Sep 17 00:00:00 2001 From: Bianca Roberta Ianosel Date: Wed, 28 Oct 2026 00:32:53 +0100 Subject: [PATCH 5/9] feat(people): register people before login and expose role assignment dates (#75) --- README.md | 10 +- drizzle/0024_people_registration.sql | 16 + drizzle/meta/0024_snapshot.json | 2595 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/auth/people-registration-command.ts | 48 + src/auth/people-registration-matching.test.ts | 60 + src/auth/people-registration-matching.ts | 62 + src/auth/people-registration.ts | 233 ++ src/auth/people-roles.integration.test.ts | 82 + src/auth/people-roles.ts | 279 +- src/db/people.ts | 22 + src/db/schema.ts | 1 + src/routes/api/internal/esign-users.ts | 6 +- src/routes/api/internal/people-roles.ts | 30 +- 14 files changed, 3358 insertions(+), 93 deletions(-) create mode 100644 drizzle/0024_people_registration.sql create mode 100644 drizzle/meta/0024_snapshot.json create mode 100644 src/auth/people-registration-command.ts create mode 100644 src/auth/people-registration-matching.test.ts create mode 100644 src/auth/people-registration-matching.ts create mode 100644 src/auth/people-registration.ts create mode 100644 src/db/people.ts diff --git a/README.md b/README.md index 60d1b0b..a5c0e1b 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,15 @@ 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, 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, 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, which of those roles they were given directly, and since when they hold each (`since`, the earliest assignment that confers it, so People shows when a role started without storing it); 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. + +### People before the first login + +People can create a preliminary person in this IdP with only first and last name, optional email/phone/Telegram contacts, and bound admin/team roles. Migration `0024_people_registration.sql` stores the declared contacts separately from provider evidence. The subject holds its roles in Auth's existing role assignments but has no credentials, provider accounts or sessions, and is excluded from the login-account/signing picker. + +The same backend-only People endpoint accepts idempotent `create-person`, `update-person` and `link-person` operations. Creation/contact editing require `admin:access` and `people:write`; assignments retain the existing delegation checks. Linking requires Master Admin and atomically transfers the preliminary subject's current bound roles to an existing login account, preserving that account's other roles and provider accounts. Names and declared contacts never grant access by themselves. + +Directory responses include stable `registrationId`, `provisional`, declared contacts and review proposals based on verified app email or Telegram identity. Homonyms and conflicting identifiers are marked for review. The dashboard's Linking page is where a Master Admin approves or rejects the proposed identity; no automatic account merge occurs on login. Deploy this IdP and migration before the corresponding backend/admin changes. ## MCP server diff --git a/drizzle/0024_people_registration.sql b/drizzle/0024_people_registration.sql new file mode 100644 index 0000000..a469d40 --- /dev/null +++ b/drizzle/0024_people_registration.sql @@ -0,0 +1,16 @@ +CREATE TABLE "people_registration" ( + "id" text PRIMARY KEY NOT NULL, + "first_name" text NOT NULL, + "last_name" text NOT NULL, + "email" text, + "phone" text, + "telegram_id" text, + "telegram_username" text, + "linked_user_id" text, + "created_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "people_registration" ADD CONSTRAINT "people_registration_id_user_id_fk" FOREIGN KEY ("id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "people_registration" ADD CONSTRAINT "people_registration_linked_user_id_user_id_fk" FOREIGN KEY ("linked_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "people_registration_linked_user_idx" ON "people_registration" USING btree ("linked_user_id"); \ No newline at end of file diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json new file mode 100644 index 0000000..0ef1086 --- /dev/null +++ b/drizzle/meta/0024_snapshot.json @@ -0,0 +1,2595 @@ +{ + "id": "65141c9c-e790-467b-bd87-381ff9d9e561", + "prevId": "6e6dc4d1-22d0-44ab-a009-fd1f763786a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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 + }, + "public.people_registration": { + "name": "people_registration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_id": { + "name": "telegram_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_user_id": { + "name": "linked_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "people_registration_linked_user_idx": { + "name": "people_registration_linked_user_idx", + "columns": [ + { + "expression": "linked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "people_registration_id_user_id_fk": { + "name": "people_registration_id_user_id_fk", + "tableFrom": "people_registration", + "tableTo": "user", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "people_registration_linked_user_id_user_id_fk": { + "name": "people_registration_linked_user_id_user_id_fk", + "tableFrom": "people_registration", + "tableTo": "user", + "columnsFrom": ["linked_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "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 9dc337d..87a601b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1792927355527, "tag": "0023_role_people_binding", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1793093534908, + "tag": "0024_people_registration", + "breakpoints": true } ] } diff --git a/src/auth/people-registration-command.ts b/src/auth/people-registration-command.ts new file mode 100644 index 0000000..9f98b05 --- /dev/null +++ b/src/auth/people-registration-command.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { peopleCommandSchema } from "./people-role-command"; + +const contact = (max: number) => z.string().trim().max(max).default(""); +export const createPeoplePersonSchema = z + .object({ + operation: z.literal("create-person"), + id: z.uuid(), + actor: z.string().min(1).max(200), + firstName: z.string().trim().min(1).max(80), + lastName: z.string().trim().min(1).max(80), + email: z.union([z.literal(""), z.email().max(254)]).default(""), + phone: contact(40), + telegramId: z.union([z.literal(""), z.string().regex(/^[1-9]\d{0,19}$/)]).default(""), + telegramUsername: contact(32).refine((value) => !value || /^[A-Za-z0-9_]{5,32}$/.test(value)), + changes: z.array(peopleCommandSchema.shape.changes.element).max(64), + }) + .superRefine((input, ctx) => { + const keys = input.changes.map((change) => + change.kind === "admin" ? "admin" : `team:${change.team}`, + ); + if (new Set(keys).size !== keys.length) + ctx.addIssue({ code: "custom", message: "Each role concept may appear only once" }); + }); +export const linkPeoplePersonSchema = z.object({ + operation: z.literal("link-person"), + id: z.uuid(), + actor: z.string().min(1).max(200), + personId: z.string().min(1).max(200), + userId: z.string().min(1).max(200), + reason: z.string().trim().min(1).max(500), +}); +export type CreatePeoplePerson = z.infer; +export type LinkPeoplePerson = z.infer; + +export const updatePeoplePersonSchema = z.object({ + operation: z.literal("update-person"), + id: z.uuid(), + actor: createPeoplePersonSchema.shape.actor, + personId: z.string().min(1).max(200), + firstName: createPeoplePersonSchema.shape.firstName, + lastName: createPeoplePersonSchema.shape.lastName, + email: createPeoplePersonSchema.shape.email, + phone: createPeoplePersonSchema.shape.phone, + telegramId: createPeoplePersonSchema.shape.telegramId, + telegramUsername: createPeoplePersonSchema.shape.telegramUsername, +}); +export type UpdatePeoplePerson = z.infer; diff --git a/src/auth/people-registration-matching.test.ts b/src/auth/people-registration-matching.test.ts new file mode 100644 index 0000000..6452c4e --- /dev/null +++ b/src/auth/people-registration-matching.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vite-plus/test"; +import { peopleRegistrationProposals } from "./people-registration-matching"; + +const person = { + id: "preliminary", + firstName: "Mario", + lastName: "Rossi", + email: "mario@example.org", + telegramId: "123", + linkedUserId: null, +}; +const account = { + sub: "login", + name: "Another name", + email: "MARIO@example.org", + emailVerified: true, + telegramId: null, +}; +describe("preliminary Auth identity proposals", () => { + it("proposes verified email and stable Telegram identities without linking", () => { + expect(peopleRegistrationProposals([person], [account])[0]).toMatchObject({ + personId: person.id, + sub: account.sub, + reason: "verified-email", + }); + expect( + peopleRegistrationProposals( + [person], + [{ ...account, emailVerified: false, telegramId: "123" }], + )[0]?.reason, + ).toBe("verified-telegram"); + }); + it("does not trust unverified mail and keeps homonyms as name-only reviews", () => { + expect(peopleRegistrationProposals([person], [{ ...account, emailVerified: false }])).toEqual( + [], + ); + expect( + peopleRegistrationProposals( + [person], + [{ ...account, email: "other@example.org", name: "Mario Rossi" }], + )[0]?.reason, + ).toBe("name-only"); + }); + it("flags ambiguous or disagreeing verified identifiers", () => { + expect( + peopleRegistrationProposals([person], [{ ...account, telegramId: "999" }])[0]?.reason, + ).toBe("conflicting-identities"); + expect( + peopleRegistrationProposals([person, { ...person, id: "duplicate" }], [account]).map( + (proposal) => proposal.reason, + ), + ).toEqual(["conflicting-identities", "conflicting-identities"]); + }); + it("does not propose preliminary subjects or accounts already claimed", () => { + expect(peopleRegistrationProposals([person], [{ ...account, sub: person.id }])).toEqual([]); + expect( + peopleRegistrationProposals([{ ...person, linkedUserId: account.sub }], [account]), + ).toEqual([]); + }); +}); diff --git a/src/auth/people-registration-matching.ts b/src/auth/people-registration-matching.ts new file mode 100644 index 0000000..46a7965 --- /dev/null +++ b/src/auth/people-registration-matching.ts @@ -0,0 +1,62 @@ +/** Declared contacts produce review proposals; they never prove identity or grant roles. */ +export function peopleRegistrationProposals( + registrations: { + id: string; + firstName: string; + lastName: string; + email: string | null; + telegramId: string | null; + linkedUserId: string | null; + }[], + accounts: { + sub: string; + name: string; + email: string | null; + emailVerified: boolean; + telegramId: string | null; + }[], +) { + const lower = (value: string | null) => value?.trim().toLowerCase() || null; + const pending = registrations.filter((person) => !person.linkedUserId); + return accounts.flatMap((account) => { + if ( + registrations.some( + (person) => person.id === account.sub || person.linkedUserId === account.sub, + ) + ) + return []; + const strong = pending.filter( + (person) => + (account.emailVerified && + lower(account.email) && + lower(account.email) === lower(person.email)) || + (account.telegramId && account.telegramId === person.telegramId), + ); + const candidates = strong.length + ? strong + : pending.filter( + (person) => lower(`${person.firstName} ${person.lastName}`) === lower(account.name), + ); + return candidates.map((person) => ({ + personId: person.id, + sub: account.sub, + name: account.name, + email: account.email, + telegramId: account.telegramId, + reason: + strong.length > 1 || + (strong.length && + account.telegramId && + person.telegramId && + account.telegramId !== person.telegramId) + ? ("conflicting-identities" as const) + : account.telegramId && account.telegramId === person.telegramId + ? ("verified-telegram" as const) + : account.emailVerified && + lower(account.email) && + lower(account.email) === lower(person.email) + ? ("verified-email" as const) + : ("name-only" as const), + })); + }); +} diff --git a/src/auth/people-registration.ts b/src/auth/people-registration.ts new file mode 100644 index 0000000..d15f44b --- /dev/null +++ b/src/auth/people-registration.ts @@ -0,0 +1,233 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { db } from "../db/index"; +import { account, peopleRegistration, rbacAuditEvent, role, user, userRole } from "../db/schema"; +import { authorizationMutationLock } from "../db/security-lock"; +import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; +import { peopleRoleChangeProblem } from "./people-binding"; +import type { + CreatePeoplePerson, + LinkPeoplePerson, + UpdatePeoplePerson, +} from "./people-registration-command"; +import { applyPeopleRoleCommand, canonical } from "./people-roles"; +import { MASTER_ADMIN_ROLE_KEY, resolveAccess } from "./rbac"; +import { assignedRoleKeys, RbacError, readCatalog } from "./rbac-store"; + +type Transaction = Parameters[0]>[0]; +async function actorAccess(tx: Transaction, actor: string) { + const catalog = await readCatalog(tx); + const subject = await readIdentitySubject(actor, tx); + const access = resolveAccess(catalog, [ + ...(await assignedRoleKeys(actor, catalog, tx)), + ...subject.roleKeys, + ]); + return { catalog, access }; +} +async function refreshActor(actor: string) { + const [known] = await db.select({ id: user.id }).from(user).where(eq(user.id, actor)); + if (!known) throw new RbacError(403, "The acting person has no account here."); + await refreshIdentityMembership(actor); +} +async function replay(tx: Transaction, id: string, input: unknown) { + const [receipt] = await tx.select().from(rbacAuditEvent).where(eq(rbacAuditEvent.id, id)); + if (!receipt) return null; + if (canonical(receipt.before) !== canonical(input)) + throw new RbacError(409, "Command ID already used with different input."); + return receipt.after as { sub: string }; +} + +/** A non-login subject holds real Auth roles; declared contacts never enter identity evidence. */ +export async function createPeoplePerson(input: CreatePeoplePerson): Promise<{ sub: string }> { + await refreshActor(input.actor); + return db.transaction(async (tx) => { + await tx.execute(authorizationMutationLock); + const receiptId = `people-person:${input.id}`; + const previous = await replay(tx, receiptId, input); + if (previous) return previous; + const { catalog, access } = await actorAccess(tx, input.actor); + if ( + !access.permissions.includes("admin:access") || + !access.permissions.includes("people:write") + ) + throw new RbacError(403, "Creating a person needs people:write and admin:access."); + for (const change of input.changes) { + const binding = change.kind === "admin" ? "admin" : `team:${change.team}:${change.role}`; + if (!catalog.roles.some((entry) => entry.peopleBinding === binding)) + throw new RbacError(409, `Bind an Auth role for ${binding} before assigning it.`); + } + const sub = `people:${input.id}`; + await tx.insert(user).values({ + id: sub, + name: `${input.firstName} ${input.lastName}`, + email: `people.${input.id}@identity.invalid`, + emailVerified: false, + }); + await tx.insert(peopleRegistration).values({ + id: sub, + firstName: input.firstName, + lastName: input.lastName, + email: input.email || null, + phone: input.phone || null, + telegramId: input.telegramId || null, + telegramUsername: input.telegramUsername || null, + createdBy: input.actor, + }); + if (input.changes.length) + await applyPeopleRoleCommand(tx, { + id: input.id, + actor: input.actor, + sub, + changes: input.changes, + reason: "Created preliminary person in Auth", + }); + const result = { sub }; + await tx.insert(rbacAuditEvent).values({ + id: receiptId, + actorId: input.actor, + operation: "people.create", + targetId: sub, + before: input, + after: result, + }); + return result; + }); +} + +/** Master approval transfers only bound roles, never credentials, sessions or provider accounts. */ +export async function linkPeoplePerson(input: LinkPeoplePerson): Promise<{ sub: string }> { + await refreshActor(input.actor); + return db.transaction(async (tx) => { + await tx.execute(authorizationMutationLock); + const receiptId = `people-link:${input.id}`; + const previous = await replay(tx, receiptId, input); + if (previous) return previous; + const { catalog, access } = await actorAccess(tx, input.actor); + if (!access.roles.includes(MASTER_ADMIN_ROLE_KEY)) + throw new RbacError(403, "A Master Admin must approve this identity link."); + const [person] = await tx + .select() + .from(peopleRegistration) + .where(eq(peopleRegistration.id, input.personId)); + if (!person) throw new RbacError(404, "Preliminary person not found."); + if (person.linkedUserId === input.userId) return { sub: input.userId }; + if (person.linkedUserId) throw new RbacError(409, "This person already has a linked account."); + const [target] = await tx + .select({ id: account.id }) + .from(account) + .where(eq(account.userId, input.userId)) + .limit(1); + const [claimed] = await tx + .select({ id: peopleRegistration.id }) + .from(peopleRegistration) + .where(eq(peopleRegistration.linkedUserId, input.userId)); + const [preliminaryTarget] = await tx + .select({ id: peopleRegistration.id }) + .from(peopleRegistration) + .where(eq(peopleRegistration.id, input.userId)); + if (!target || claimed || preliminaryTarget) + throw new RbacError( + 409, + "Choose an existing login account that is not linked to another preliminary person.", + ); + const assignments = await tx + .select({ roleId: userRole.roleId, assignedAt: userRole.assignedAt, key: role.key }) + .from(userRole) + .innerJoin(role, eq(userRole.roleId, role.id)) + .where(eq(userRole.userId, input.personId)); + for (const assignment of assignments) { + const assigned = catalog.roles.find((entry) => entry.key === assignment.key); + if (!assigned) throw new RbacError(409, "An assigned role no longer exists."); + const problem = peopleRoleChangeProblem({ + catalog, + actorId: input.actor, + actor: access, + targetId: input.userId, + role: assigned, + action: "assign", + }); + if (problem) throw new RbacError(403, problem); + } + for (const assignment of assignments) + await tx + .insert(userRole) + .values({ + userId: input.userId, + roleId: assignment.roleId, + assignedAt: assignment.assignedAt, + assignedBy: input.actor, + }) + .onConflictDoNothing(); + if (assignments.length) + await tx.delete(userRole).where( + and( + eq(userRole.userId, input.personId), + inArray( + userRole.roleId, + assignments.map((entry) => entry.roleId), + ), + ), + ); + await tx + .update(peopleRegistration) + .set({ linkedUserId: input.userId }) + .where(eq(peopleRegistration.id, input.personId)); + const result = { sub: input.userId }; + await tx.insert(rbacAuditEvent).values({ + id: receiptId, + actorId: input.actor, + operation: "people.link", + targetId: input.personId, + before: input, + after: { ...result, roles: assignments.map((entry) => entry.key) }, + }); + return result; + }); +} + +/** Contact corrections stay in Auth; none of these declared values are verification evidence. */ +export async function updatePeoplePerson(input: UpdatePeoplePerson): Promise<{ sub: string }> { + await refreshActor(input.actor); + return db.transaction(async (tx) => { + await tx.execute(authorizationMutationLock); + const receiptId = `people-update:${input.id}`; + const previous = await replay(tx, receiptId, input); + if (previous) return previous; + const { access } = await actorAccess(tx, input.actor); + if ( + !access.permissions.includes("admin:access") || + !access.permissions.includes("people:write") + ) + throw new RbacError(403, "Changing contacts needs people:write and admin:access."); + const [person] = await tx + .select() + .from(peopleRegistration) + .where(eq(peopleRegistration.id, input.personId)); + if (!person) throw new RbacError(404, "Auth person not found."); + await tx + .update(peopleRegistration) + .set({ + firstName: input.firstName, + lastName: input.lastName, + email: input.email || null, + phone: input.phone || null, + telegramId: input.telegramId || null, + telegramUsername: input.telegramUsername || null, + }) + .where(eq(peopleRegistration.id, input.personId)); + if (!person.linkedUserId) + await tx + .update(user) + .set({ name: `${input.firstName} ${input.lastName}` }) + .where(eq(user.id, input.personId)); + const result = { sub: person.linkedUserId ?? person.id }; + await tx.insert(rbacAuditEvent).values({ + id: receiptId, + actorId: input.actor, + operation: "people.update", + targetId: person.id, + before: input, + after: result, + }); + return result; + }); +} diff --git a/src/auth/people-roles.integration.test.ts b/src/auth/people-roles.integration.test.ts index 14d048e..46ec9ec 100644 --- a/src/auth/people-roles.integration.test.ts +++ b/src/auth/people-roles.integration.test.ts @@ -30,6 +30,7 @@ vi.mock("./membership", () => ({ })); import { db } from "../db/index"; +import { createPeoplePerson, linkPeoplePerson } from "./people-registration"; import { changePeopleRoles, readPeopleDirectory } from "./people-roles"; import { assignRole, saveRole, setRolePeopleBinding } from "./rbac-store"; @@ -143,7 +144,88 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( expect(directory.users.find((entry) => entry.sub === target)).toMatchObject({ roles: [`${team}-member`], assigned: [`${team}-member`], + since: { [`${team}-member`]: new Date().toISOString().slice(0, 10) }, }); }); + it("creates an Auth person before login and requires Master approval to bind the real account", async () => { + const id = randomUUID(); + const input = { + operation: "create-person" as const, + id, + actor: hr, + firstName: "Pre", + lastName: "Login", + email: "pre-login@example.org", + phone: "", + telegramId: "", + telegramUsername: "", + changes: [{ kind: "team" as const, team, role: "member" as const }], + }; + const created = await createPeoplePerson(input); + try { + expect(await createPeoplePerson(input)).toEqual(created); + await expect( + createPeoplePerson({ ...input, firstName: "Different" }), + ).rejects.toMatchObject({ status: 409 }); + expect( + (await pool.query("SELECT id FROM account WHERE user_id = $1", [created.sub])).rows, + ).toEqual([]); + const preliminary = (await readPeopleDirectory()).users.find( + (entry) => entry.sub === created.sub, + ); + expect(preliminary).toMatchObject({ + provisional: true, + registrationId: created.sub, + roles: [`${team}-member`], + email: input.email, + }); + await pool.query('UPDATE "user" SET email = $1, email_verified = true WHERE id = $2', [ + input.email, + target, + ]); + await pool.query( + "INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) VALUES ($1, $2, $3, $4, $5, NOW())", + [randomUUID(), randomUUID(), "google", "https://accounts.google.com", target], + ); + expect((await readPeopleDirectory()).proposals).toContainEqual( + expect.objectContaining({ personId: created.sub, sub: target, reason: "verified-email" }), + ); + const link = { + operation: "link-person" as const, + id: randomUUID(), + actor: hr, + personId: created.sub, + userId: target, + reason: "Checked verified identity", + }; + await expect(linkPeoplePerson(link)).rejects.toMatchObject({ status: 403 }); + expect( + (await readPeopleDirectory()).users.find((entry) => entry.sub === created.sub) + ?.provisional, + ).toBe(true); + const approved = { ...link, actor: root }; + expect(await linkPeoplePerson(approved)).toMatchObject({ sub: target }); + expect(await linkPeoplePerson(approved)).toMatchObject({ sub: target }); + expect(await linkPeoplePerson({ ...approved, id: randomUUID() })).toMatchObject({ + sub: target, + }); + expect( + (await readPeopleDirectory()).users.find((entry) => entry.sub === target), + ).toMatchObject({ + registrationId: created.sub, + provisional: false, + previousSub: created.sub, + roles: expect.arrayContaining([`${team}-member`]), + }); + expect((await readPeopleDirectory()).users.some((entry) => entry.sub === created.sub)).toBe( + false, + ); + expect( + (await pool.query("SELECT id FROM account WHERE user_id = $1", [target])).rows, + ).toHaveLength(1); + } finally { + await pool.query('DELETE FROM "user" WHERE id = $1', [created.sub]); + } + }); }, ); diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index 830bb8b..0869296 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -1,9 +1,18 @@ import { randomUUID } from "node:crypto"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } 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 { + account, + identityEvidence, + rbacAuditEvent, + role, + user, + userRole, + peopleRegistration, +} from "../db/schema"; +import { peopleRegistrationProposals } from "./people-registration-matching"; +import { appEmail, contactEmails } from "./contact-email"; import { logAuthorizationDenial } from "./denial-log"; import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; import { @@ -28,8 +37,17 @@ export type PeopleDirectory = { * 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. */ + proposals?: ReturnType; users: { sub: string; + registrationId?: string; + provisional?: boolean; + previousSub?: string; + firstName?: string; + lastName?: string; + phone?: string | null; + declaredTelegramId?: string | null; + declaredTelegramUsername?: string | null; name: string; /** Their PoliNetwork address when they linked it, otherwise their sign-in address. */ email: string | null; @@ -41,6 +59,11 @@ export type PeopleDirectory = { roles: string[]; /** Those of `roles` given to them directly: the only ones that can be taken back. */ assigned: string[]; + /** + * When each of `roles` was first given (`YYYY-MM-DD`, UTC): the earliest assignment that + * confers it, directly or through a parent role. + */ + since: Record; }[]; }; @@ -62,10 +85,10 @@ export async function readPeopleDirectory(): Promise { name: entry.name, binding: entry.peopleBinding!, })); - if (!bound.length) return { roles, users: [] }; + const registrations = await transaction.select().from(peopleRegistration); 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)); @@ -77,6 +100,14 @@ export async function readPeopleDirectory(): Promise { const roles = expandRoleKeys(catalog, keys).filter((key) => boundKeys.has(key)); if (roles.length) held.set(userId, roles); } + const since = new Map>(); + for (const row of assignments) { + 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); + } // 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 @@ -94,12 +125,22 @@ export async function readPeopleDirectory(): Promise { 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 = [ + ...new Set([ + ...held.keys(), + ...azureByUser.keys(), + ...registrations.map((person) => person.linkedUserId ?? person.id), + ]), + ].filter((id) => !registrations.some((person) => person.id === id && person.linkedUserId)); if (!ids.length) return { roles, users: [] }; const people = await transaction - .select({ id: user.id, name: user.name, email: user.email }) + .select({ + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + }) .from(user) - .where(inArray(user.id, ids)) .orderBy(user.id); const emails = await contactEmails(transaction, people); const telegram = await transaction @@ -107,6 +148,8 @@ export async function readPeopleDirectory(): Promise { userId: account.userId, telegramId: identityEvidence.telegramId, telegramUsername: identityEvidence.telegramUsername, + issuer: identityEvidence.issuer, + validUntil: identityEvidence.validUntil, }) .from(account) .innerJoin( @@ -117,21 +160,64 @@ export async function readPeopleDirectory(): Promise { 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) => ({ + .where(eq(account.providerId, "telegram")); + const telegramByUser = new Map( + telegram + .filter( + (row) => row.issuer === "https://oauth.telegram.org" && row.validUntil > new Date(), + ) + .map((row) => [row.userId, row]), + ); + const verifiedEmails = await Promise.all( + people.map((person) => appEmail(transaction, person)), + ); + const proposals = peopleRegistrationProposals( + registrations, + people.map((person, index) => ({ sub: person.id, name: person.name, - email: emails.get(person.id) ?? null, - azureId: azureByUser.get(person.id) ?? null, + email: verifiedEmails[index]!.email, + emailVerified: verifiedEmails[index]!.emailVerified, 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(), })), + ); + + return { + roles, + proposals, + users: people + .filter((person) => ids.includes(person.id)) + .map((person) => { + const registration = registrations.find( + (entry) => (entry.linkedUserId ?? entry.id) === person.id, + ); + return { + ...(registration + ? { + registrationId: registration.id, + provisional: !registration.linkedUserId, + previousSub: registration.linkedUserId ? registration.id : undefined, + firstName: registration.firstName, + lastName: registration.lastName, + phone: registration.phone, + declaredTelegramId: registration.telegramId, + declaredTelegramUsername: registration.telegramUsername, + } + : {}), + sub: person.id, + name: person.name, + email: + registration && !registration.linkedUserId + ? registration.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(), + since: since.get(person.id) ?? {}, + }; + }), }; }, { isolationLevel: "repeatable read", accessMode: "read only" }, @@ -142,7 +228,7 @@ 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 { +export function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object") return `{${Object.entries(value) @@ -200,79 +286,96 @@ export async function changePeopleRoles(input: PeopleCommand): Promise { 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."); + return applyPeopleRoleCommand(tx, input); + }); +} - 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.actor, - actor: access, - targetId: input.sub, - role: step.target, - action: step.action, - }); - if (problem) { - logAuthorizationDenial(input.actor, "people-roles", [`${step.action}:${step.target.key}`]); - throw new RbacError(403, problem); - } - } +/** Caller holds the authorization lock and has refreshed the acting account membership. */ +export async function applyPeopleRoleCommand( + tx: Transaction, + input: PeopleCommand, +): Promise { + 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 [registration] = await tx + .select({ linkedUserId: peopleRegistration.linkedUserId }) + .from(peopleRegistration) + .where(eq(peopleRegistration.id, input.sub)); + if (registration?.linkedUserId) + throw new RbacError( + 409, + "This preliminary person now belongs to a login account; refresh People before editing roles.", + ); + 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."); - 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.actor, - operation, - targetId: step.target.id, - before, - after: { - ...after, - via: "people", - userId: input.sub, - reason: input.reason, - commandId: input.id, - }, - }); + 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.actor, + actor: access, + targetId: input.sub, + role: step.target, + action: step.action, + }); + if (problem) { + logAuthorizationDenial(input.actor, "people-roles", [`${step.action}:${step.target.key}`]); + throw new RbacError(403, problem); } + } + + 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: receiptId, + id: randomUUID(), actorId: input.actor, - operation: "people.roles", - targetId: input.sub, - before: input, - after: plan.result, + operation, + targetId: step.target.id, + before, + after: { + ...after, + via: "people", + userId: input.sub, + reason: input.reason, + commandId: input.id, + }, }); - return plan.result; + } + await tx.insert(rbacAuditEvent).values({ + id: `people-command:${input.id}`, + actorId: input.actor, + operation: "people.roles", + targetId: input.sub, + before: input, + after: plan.result, }); + return plan.result; } diff --git a/src/db/people.ts b/src/db/people.ts new file mode 100644 index 0000000..1e60846 --- /dev/null +++ b/src/db/people.ts @@ -0,0 +1,22 @@ +import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { user } from "./auth-schema"; + +/** Declared contacts are not provider evidence and must never establish a login identity. */ +export const peopleRegistration = pgTable( + "people_registration", + { + id: text() + .primaryKey() + .references(() => user.id, { onDelete: "cascade" }), + firstName: text("first_name").notNull(), + lastName: text("last_name").notNull(), + email: text(), + phone: text(), + telegramId: text("telegram_id"), + telegramUsername: text("telegram_username"), + linkedUserId: text("linked_user_id").references(() => user.id), + createdBy: text("created_by").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [uniqueIndex("people_registration_linked_user_idx").on(table.linkedUserId)], +); diff --git a/src/db/schema.ts b/src/db/schema.ts index 79fecb4..12c76df 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -2,3 +2,4 @@ export * from "./auth-schema"; export * from "./evidence"; export * from "./rbac"; export * from "./access-snapshot"; +export * from "./people"; diff --git a/src/routes/api/internal/esign-users.ts b/src/routes/api/internal/esign-users.ts index c3e1799..90a411f 100644 --- a/src/routes/api/internal/esign-users.ts +++ b/src/routes/api/internal/esign-users.ts @@ -1,10 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; -import { asc } from "drizzle-orm"; +import { asc, eq, isNull } from "drizzle-orm"; import { snapshotClientId } from "@/auth/access-snapshot-token"; import { contactEmails } from "@/auth/contact-email"; import { projectionForClient } from "@/auth/access-projections"; import { db } from "@/db"; -import { user } from "@/db/schema"; +import { user, peopleRegistration } from "@/db/schema"; import { env } from "@/env"; const headers = { "Cache-Control": "private, no-store", Vary: "Authorization" }; @@ -26,6 +26,8 @@ export const Route = createFileRoute("/api/internal/esign-users")({ const people = await db .select({ id: user.id, name: user.name, email: user.email }) .from(user) + .leftJoin(peopleRegistration, eq(peopleRegistration.id, user.id)) + .where(isNull(peopleRegistration.id)) .orderBy(asc(user.name), asc(user.id)); const emails = await contactEmails(db, people, "everyone"); return Response.json( diff --git a/src/routes/api/internal/people-roles.ts b/src/routes/api/internal/people-roles.ts index 345164a..f14316f 100644 --- a/src/routes/api/internal/people-roles.ts +++ b/src/routes/api/internal/people-roles.ts @@ -1,3 +1,14 @@ +import { + createPeoplePersonSchema, + updatePeoplePersonSchema, + linkPeoplePersonSchema, +} from "@/auth/people-registration-command"; +import { + createPeoplePerson, + linkPeoplePerson, + updatePeoplePerson, +} from "@/auth/people-registration"; +import { z } from "zod"; import { createFileRoute } from "@tanstack/react-router"; import { peopleCommandSchema } from "@/auth/people-role-command"; import { serviceClientId } from "@/auth/access-snapshot-token"; @@ -35,11 +46,26 @@ 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 = peopleCommandSchema.safeParse(await request.json().catch(() => null)); + const parsed = z + .union([ + createPeoplePersonSchema, + updatePeoplePersonSchema, + linkPeoplePersonSchema, + peopleCommandSchema, + ]) + .safeParse(await request.json().catch(() => null)); if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400, headers }); try { - const result = await changePeopleRoles(parsed.data); + const command = parsed.data; + const result = + "operation" in command + ? command.operation === "create-person" + ? await createPeoplePerson(command) + : command.operation === "update-person" + ? await updatePeoplePerson(command) + : await linkPeoplePerson(command) + : await changePeopleRoles(command); return Response.json(result, { headers }); } catch (error) { if (error instanceof RbacError) From 1d08a153199416b9164c5453661680ca047d0f94 Mon Sep 17 00:00:00 2001 From: Bianca Date: Wed, 28 Oct 2026 00:58:01 +0100 Subject: [PATCH 6/9] fix(people): keep Telegram matches after sign-in expiry and delete linked registrations - The People directory no longer drops Telegram evidence once its sign-in token expires, so the Telegram ID, username and verified-telegram proposals persist. - Verified app emails come from the existing Entra query instead of one query per user. - Deleting an account also deletes the People registration linked to it and its declared contacts; the linked_user_id foreign key now cascades. - The admin binding is labelled as a PoliNetwork admin, not a Telegram group admin. --- README.md | 2 +- drizzle/0024_people_registration.sql | 2 +- drizzle/meta/0024_snapshot.json | 2 +- src/auth/contact-email.ts | 17 +++++-- src/auth/mcp-server.ts | 2 +- src/auth/people-binding.ts | 2 +- src/auth/people-roles.integration.test.ts | 51 ++++++++++++++++++++ src/auth/people-roles.ts | 53 +++++++++++---------- src/auth/user-deletion.integration.test.mjs | 22 +++++++++ src/auth/user-deletion.ts | 16 ++++++- src/components/rbac/people-binding-card.tsx | 2 +- src/db/people.ts | 2 +- 12 files changed, 138 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index a5c0e1b..d3222e5 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ 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. +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 PoliNetwork admins (`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, 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, which of those roles they were given directly, and since when they hold each (`since`, the earliest assignment that confers it, so People shows when a role started without storing it); 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. diff --git a/drizzle/0024_people_registration.sql b/drizzle/0024_people_registration.sql index a469d40..75c59eb 100644 --- a/drizzle/0024_people_registration.sql +++ b/drizzle/0024_people_registration.sql @@ -12,5 +12,5 @@ CREATE TABLE "people_registration" ( ); --> statement-breakpoint ALTER TABLE "people_registration" ADD CONSTRAINT "people_registration_id_user_id_fk" FOREIGN KEY ("id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "people_registration" ADD CONSTRAINT "people_registration_linked_user_id_user_id_fk" FOREIGN KEY ("linked_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "people_registration" ADD CONSTRAINT "people_registration_linked_user_id_user_id_fk" FOREIGN KEY ("linked_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE UNIQUE INDEX "people_registration_linked_user_idx" ON "people_registration" USING btree ("linked_user_id"); \ No newline at end of file diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json index 0ef1086..d1ed319 100644 --- a/drizzle/meta/0024_snapshot.json +++ b/drizzle/meta/0024_snapshot.json @@ -2570,7 +2570,7 @@ "tableTo": "user", "columnsFrom": ["linked_user_id"], "columnsTo": ["id"], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, diff --git a/src/auth/contact-email.ts b/src/auth/contact-email.ts index baa3b6a..0f56259 100644 --- a/src/auth/contact-email.ts +++ b/src/auth/contact-email.ts @@ -146,9 +146,20 @@ export async function appEmail( ), ) .where(and(eq(account.userId, user.id), eq(account.providerId, "pn-entra"))); - const organization = linked - .map((row) => row.email) - .find((email) => email?.toLowerCase().endsWith("@polinetwork.org")); + return appEmailFrom( + user, + linked.map((row) => row.email), + ); +} + +/** `appEmail` for callers that already read the addresses of the user's Entra evidence. */ +export function appEmailFrom( + user: { email: string; emailVerified: boolean }, + entraEmails: (string | null)[], +) { + const organization = entraEmails.find((email) => + email?.toLowerCase().endsWith("@polinetwork.org"), + ); if (organization) return { email: organization, emailVerified: true }; if (isPlaceholderEmail(user.email)) return { email: null, emailVerified: false }; return { email: user.email, emailVerified: user.emailVerified }; diff --git a/src/auth/mcp-server.ts b/src/auth/mcp-server.ts index 7dd4db0..13d8f00 100644 --- a/src/auth/mcp-server.ts +++ b/src/auth/mcp-server.ts @@ -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 (PoliNetwork admins), team::member or team::lead, with the dashboard's team key: it, hr, design, intl or events.", ), }), annotations: { idempotentHint: true }, diff --git a/src/auth/people-binding.ts b/src/auth/people-binding.ts index 661ed79..20866db 100644 --- a/src/auth/people-binding.ts +++ b/src/auth/people-binding.ts @@ -32,7 +32,7 @@ export function parsePeopleBinding(value: string | null | undefined): PeopleBind } export function describePeopleBinding(binding: PeopleBinding) { - if (binding.kind === "admin") return "Admin of the Telegram groups"; + if (binding.kind === "admin") return "PoliNetwork admin"; return `${binding.role === "lead" ? "Lead" : "Member"} of team ${binding.team}`; } diff --git a/src/auth/people-roles.integration.test.ts b/src/auth/people-roles.integration.test.ts index 46ec9ec..c14fbd8 100644 --- a/src/auth/people-roles.integration.test.ts +++ b/src/auth/people-roles.integration.test.ts @@ -227,5 +227,56 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( await pool.query('DELETE FROM "user" WHERE id = $1', [created.sub]); } }); + + it("keeps a Telegram sign-in's ID and match after its token has expired", async () => { + const telegramId = String(Date.now()); + const created = await createPeoplePerson({ + operation: "create-person", + id: randomUUID(), + actor: hr, + firstName: "Tele", + lastName: "Gram", + email: "", + phone: "", + telegramId, + telegramUsername: "", + changes: [{ kind: "team", team, role: "member" }], + }); + const member = `people-telegram-${randomUUID().slice(0, 8)}`; + const subject = `${member}-telegram`; + try { + await pool.query( + `INSERT INTO "user" (id, name, email) VALUES ($1, 'Tele Gram', $1 || '@identity.invalid')`, + [member], + ); + await pool.query( + `INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) + VALUES ($1, $2, 'telegram', 'https://oauth.telegram.org', $3, NOW())`, + [randomUUID(), subject, member], + ); + // For Telegram, `valid_until` is the sign-in token's expiry. + await pool.query( + `INSERT INTO identity_evidence (issuer, subject, provider_id, states, valid_until, telegram_id, telegram_username) + VALUES ('https://oauth.telegram.org', $1, 'telegram', '{}', NOW() - interval '1 day', $2, 'tele_gram')`, + [subject, telegramId], + ); + await assignRole(root, roles[`${team}-admin`]!, member); + const directory = await readPeopleDirectory(); + expect(directory.proposals).toContainEqual( + expect.objectContaining({ + personId: created.sub, + sub: member, + reason: "verified-telegram", + }), + ); + expect(directory.users.find((entry) => entry.sub === member)).toMatchObject({ + telegramId, + telegramUsername: "tele_gram", + }); + } finally { + await pool.query(`DELETE FROM identity_evidence WHERE subject = $1`, [subject]); + await pool.query('DELETE FROM "user" WHERE id = ANY($1)', [[member, created.sub]]); + } + }); }, ); diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index 0869296..5dc2a11 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -12,7 +12,7 @@ import { peopleRegistration, } from "../db/schema"; import { peopleRegistrationProposals } from "./people-registration-matching"; -import { appEmail, contactEmails } from "./contact-email"; +import { appEmailFrom, contactEmails } from "./contact-email"; import { logAuthorizationDenial } from "./denial-log"; import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; import { @@ -111,7 +111,11 @@ export async function readPeopleDirectory(): Promise { // 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 }) + .select({ + userId: account.userId, + azureId: identityEvidence.externalId, + email: identityEvidence.email, + }) .from(account) .innerJoin( identityEvidence, @@ -125,14 +129,22 @@ export async function readPeopleDirectory(): Promise { const azureByUser = new Map( entra.flatMap((row) => (row.azureId ? [[row.userId, row.azureId] as const] : [])), ); - const ids = [ - ...new Set([ - ...held.keys(), - ...azureByUser.keys(), - ...registrations.map((person) => person.linkedUserId ?? person.id), - ]), - ].filter((id) => !registrations.some((person) => person.id === id && person.linkedUserId)); - if (!ids.length) return { roles, users: [] }; + const entraEmails = new Map(); + for (const row of entra) + entraEmails.set(row.userId, [...(entraEmails.get(row.userId) ?? []), row.email]); + // A registration answers for its login account once linked, for itself until then. + const registrationBySub = new Map( + registrations.map((person) => [person.linkedUserId ?? person.id, person]), + ); + const linkedPlaceholders = new Set( + registrations.flatMap((person) => (person.linkedUserId ? [person.id] : [])), + ); + const ids = new Set( + [...held.keys(), ...azureByUser.keys(), ...registrationBySub.keys()].filter( + (id) => !linkedPlaceholders.has(id), + ), + ); + if (!ids.size) return { roles, users: [] }; const people = await transaction .select({ id: user.id, @@ -149,7 +161,6 @@ export async function readPeopleDirectory(): Promise { telegramId: identityEvidence.telegramId, telegramUsername: identityEvidence.telegramUsername, issuer: identityEvidence.issuer, - validUntil: identityEvidence.validUntil, }) .from(account) .innerJoin( @@ -161,23 +172,19 @@ export async function readPeopleDirectory(): Promise { ), ) .where(eq(account.providerId, "telegram")); + // Not filtered on `validUntil`: for Telegram that is the sign-in token's expiry, while + // the Telegram ID it proved stays theirs, as in the access snapshot. const telegramByUser = new Map( telegram - .filter( - (row) => row.issuer === "https://oauth.telegram.org" && row.validUntil > new Date(), - ) + .filter((row) => row.issuer === "https://oauth.telegram.org") .map((row) => [row.userId, row]), ); - const verifiedEmails = await Promise.all( - people.map((person) => appEmail(transaction, person)), - ); const proposals = peopleRegistrationProposals( registrations, - people.map((person, index) => ({ + people.map((person) => ({ sub: person.id, name: person.name, - email: verifiedEmails[index]!.email, - emailVerified: verifiedEmails[index]!.emailVerified, + ...appEmailFrom(person, entraEmails.get(person.id) ?? []), telegramId: telegramByUser.get(person.id)?.telegramId ?? null, })), ); @@ -186,11 +193,9 @@ export async function readPeopleDirectory(): Promise { roles, proposals, users: people - .filter((person) => ids.includes(person.id)) + .filter((person) => ids.has(person.id)) .map((person) => { - const registration = registrations.find( - (entry) => (entry.linkedUserId ?? entry.id) === person.id, - ); + const registration = registrationBySub.get(person.id); return { ...(registration ? { diff --git a/src/auth/user-deletion.integration.test.mjs b/src/auth/user-deletion.integration.test.mjs index 2461318..2c1e87f 100644 --- a/src/auth/user-deletion.integration.test.mjs +++ b/src/auth/user-deletion.integration.test.mjs @@ -290,4 +290,26 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("account deletion with Post for (const personal of [target.name, "@example.org", "4242", `${target.id}-oid`]) expect(recorded).not.toContain(personal); }); + + it("also removes the People registration linked to the account and its declared contacts", async () => { + const target = await person(); + const registered = unique("people"); + await pool.query( + `INSERT INTO "user" (id, name, email) VALUES ($1, 'Pre Login', $1 || '@identity.invalid')`, + [registered], + ); + await pool.query( + `INSERT INTO people_registration (id, first_name, last_name, email, phone, linked_user_id, created_by) + VALUES ($1, 'Pre', 'Login', 'declared@example.org', '+39000', $2, $3)`, + [registered, target.id, root], + ); + + const response = await remove(deleter, target.id, target.name); + expect(response.status).toBe(200); + expect(await exists(target.id)).toBe(false); + expect(await exists(registered)).toBe(false); + expect( + (await pool.query(`SELECT 1 FROM people_registration WHERE id = $1`, [registered])).rowCount, + ).toBe(0); + }); }); diff --git a/src/auth/user-deletion.ts b/src/auth/user-deletion.ts index 3b382cc..1c3ae21 100644 --- a/src/auth/user-deletion.ts +++ b/src/auth/user-deletion.ts @@ -1,10 +1,11 @@ import { randomUUID } from "node:crypto"; -import { and, eq, isNotNull, sql } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, sql } from "drizzle-orm"; import { db } from "../db/index"; import { account, identityEvidence, oauthClient, + peopleRegistration, rbacAuditEvent, session, user, @@ -197,6 +198,19 @@ export async function deleteUser(actorId: string, userId: string, confirmation: const context = await auth.$context; await context.internalAdapter.deleteSessions(sessions.map((entry) => entry.token)); } + // A person People registered before this account's first sign-in goes too: their + // registration only holds the contacts declared for them. + const registered = await transaction + .select({ id: peopleRegistration.id }) + .from(peopleRegistration) + .where(eq(peopleRegistration.linkedUserId, userId)); + if (registered.length) + await transaction.delete(user).where( + inArray( + user.id, + registered.map((entry) => entry.id), + ), + ); await transaction.delete(user).where(eq(user.id, userId)); }, ); diff --git a/src/components/rbac/people-binding-card.tsx b/src/components/rbac/people-binding-card.tsx index 9b19809..e7785c2 100644 --- a/src/components/rbac/people-binding-card.tsx +++ b/src/components/rbac/people-binding-card.tsx @@ -21,7 +21,7 @@ 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: "admin", label: "PoliNetwork admin" }, { value: "member", label: "Team member" }, { value: "lead", label: "Team Lead" }, ]; diff --git a/src/db/people.ts b/src/db/people.ts index 1e60846..3859d96 100644 --- a/src/db/people.ts +++ b/src/db/people.ts @@ -14,7 +14,7 @@ export const peopleRegistration = pgTable( phone: text(), telegramId: text("telegram_id"), telegramUsername: text("telegram_username"), - linkedUserId: text("linked_user_id").references(() => user.id), + linkedUserId: text("linked_user_id").references(() => user.id, { onDelete: "cascade" }), createdBy: text("created_by").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }, From ce9dbc3c30d0b9e893caa00176173dff0dde99b3 Mon Sep 17 00:00:00 2001 From: Bianca Date: Wed, 28 Oct 2026 01:10:31 +0100 Subject: [PATCH 7/9] feat(people): propose a link from a declared Telegram username A declared Telegram username that matches a signed-in Telegram account now produces a `telegram-username` proposal, case-insensitive and with or without @. It ranks below a verified email or Telegram ID, because usernames can change hands, and a Master Admin still decides the link. --- README.md | 2 +- src/auth/people-registration-matching.test.ts | 29 +++++++++++++++++++ src/auth/people-registration-matching.ts | 27 +++++++++++++---- src/auth/people-roles.integration.test.ts | 28 +++++++++++++++++- src/auth/people-roles.ts | 1 + 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d3222e5..c955efd 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,7 @@ People can create a preliminary person in this IdP with only first and last name The same backend-only People endpoint accepts idempotent `create-person`, `update-person` and `link-person` operations. Creation/contact editing require `admin:access` and `people:write`; assignments retain the existing delegation checks. Linking requires Master Admin and atomically transfers the preliminary subject's current bound roles to an existing login account, preserving that account's other roles and provider accounts. Names and declared contacts never grant access by themselves. -Directory responses include stable `registrationId`, `provisional`, declared contacts and review proposals based on verified app email or Telegram identity. Homonyms and conflicting identifiers are marked for review. The dashboard's Linking page is where a Master Admin approves or rejects the proposed identity; no automatic account merge occurs on login. Deploy this IdP and migration before the corresponding backend/admin changes. +Directory responses include stable `registrationId`, `provisional`, declared contacts and review proposals based on verified app email or Telegram identity. A declared Telegram username that matches a signed-in Telegram account is proposed as `telegram-username`, a weaker reason because usernames can change hands. Homonyms and conflicting identifiers are marked for review. The dashboard's Linking page is where a Master Admin approves or rejects the proposed identity; no automatic account merge occurs on login. Deploy this IdP and migration before the corresponding backend/admin changes. ## MCP server diff --git a/src/auth/people-registration-matching.test.ts b/src/auth/people-registration-matching.test.ts index 6452c4e..9c30f3e 100644 --- a/src/auth/people-registration-matching.test.ts +++ b/src/auth/people-registration-matching.test.ts @@ -7,6 +7,7 @@ const person = { lastName: "Rossi", email: "mario@example.org", telegramId: "123", + telegramUsername: null, linkedUserId: null, }; const account = { @@ -15,6 +16,7 @@ const account = { email: "MARIO@example.org", emailVerified: true, telegramId: null, + telegramUsername: null, }; describe("preliminary Auth identity proposals", () => { it("proposes verified email and stable Telegram identities without linking", () => { @@ -30,6 +32,33 @@ describe("preliminary Auth identity proposals", () => { )[0]?.reason, ).toBe("verified-telegram"); }); + it("proposes a declared Telegram username, with or without @, as its own weaker reason", () => { + const declared = { ...person, telegramId: null, telegramUsername: "@Anna_PN" }; + const signedIn = { + ...account, + emailVerified: false, + telegramId: "77", + telegramUsername: "anna_pn", + }; + expect(peopleRegistrationProposals([declared], [signedIn])[0]).toMatchObject({ + personId: person.id, + sub: account.sub, + reason: "telegram-username", + }); + // A declared Telegram ID that disagrees still wins over the username. + expect( + peopleRegistrationProposals([{ ...declared, telegramId: "88" }], [signedIn])[0]?.reason, + ).toBe("conflicting-identities"); + expect( + peopleRegistrationProposals([declared], [{ ...signedIn, telegramUsername: "someone" }]), + ).toEqual([]); + // A verified Telegram ID or email outranks a username declared on another record. + expect( + peopleRegistrationProposals([{ ...person, telegramId: "77" }, declared], [signedIn]).map( + (proposal) => proposal.reason, + ), + ).toEqual(["verified-telegram"]); + }); it("does not trust unverified mail and keeps homonyms as name-only reviews", () => { expect(peopleRegistrationProposals([person], [{ ...account, emailVerified: false }])).toEqual( [], diff --git a/src/auth/people-registration-matching.ts b/src/auth/people-registration-matching.ts index 46a7965..ef77c12 100644 --- a/src/auth/people-registration-matching.ts +++ b/src/auth/people-registration-matching.ts @@ -6,6 +6,7 @@ export function peopleRegistrationProposals( lastName: string; email: string | null; telegramId: string | null; + telegramUsername: string | null; linkedUserId: string | null; }[], accounts: { @@ -14,9 +15,12 @@ export function peopleRegistrationProposals( email: string | null; emailVerified: boolean; telegramId: string | null; + telegramUsername: string | null; }[], ) { const lower = (value: string | null) => value?.trim().toLowerCase() || null; + // Usernames can change hands, so a match proposes the link but is not a verified identity. + const username = (value: string | null) => lower(value)?.replace(/^@/, "") || null; const pending = registrations.filter((person) => !person.linkedUserId); return accounts.flatMap((account) => { if ( @@ -32,11 +36,20 @@ export function peopleRegistrationProposals( lower(account.email) === lower(person.email)) || (account.telegramId && account.telegramId === person.telegramId), ); - const candidates = strong.length - ? strong + const byUsername = strong.length + ? [] : pending.filter( - (person) => lower(`${person.firstName} ${person.lastName}`) === lower(account.name), + (person) => + username(account.telegramUsername) && + username(account.telegramUsername) === username(person.telegramUsername), ); + const candidates = strong.length + ? strong + : byUsername.length + ? byUsername + : pending.filter( + (person) => lower(`${person.firstName} ${person.lastName}`) === lower(account.name), + ); return candidates.map((person) => ({ personId: person.id, sub: account.sub, @@ -45,7 +58,8 @@ export function peopleRegistrationProposals( telegramId: account.telegramId, reason: strong.length > 1 || - (strong.length && + byUsername.length > 1 || + ((strong.length || byUsername.length) && account.telegramId && person.telegramId && account.telegramId !== person.telegramId) @@ -56,7 +70,10 @@ export function peopleRegistrationProposals( lower(account.email) && lower(account.email) === lower(person.email) ? ("verified-email" as const) - : ("name-only" as const), + : username(account.telegramUsername) && + username(account.telegramUsername) === username(person.telegramUsername) + ? ("telegram-username" as const) + : ("name-only" as const), })); }); } diff --git a/src/auth/people-roles.integration.test.ts b/src/auth/people-roles.integration.test.ts index c14fbd8..3dc0505 100644 --- a/src/auth/people-roles.integration.test.ts +++ b/src/auth/people-roles.integration.test.ts @@ -242,6 +242,18 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( telegramUsername: "", changes: [{ kind: "team", team, role: "member" }], }); + const byUsername = await createPeoplePerson({ + operation: "create-person", + id: randomUUID(), + actor: hr, + firstName: "Only", + lastName: "Username", + email: "", + phone: "", + telegramId: "", + telegramUsername: "Tele_Gram", + changes: [{ kind: "team", team, role: "member" }], + }); const member = `people-telegram-${randomUUID().slice(0, 8)}`; const subject = `${member}-telegram`; try { @@ -269,13 +281,27 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( reason: "verified-telegram", }), ); + // The verified Telegram ID outranks the username declared on another record. + expect(directory.proposals?.some((proposal) => proposal.personId === byUsername.sub)).toBe( + false, + ); expect(directory.users.find((entry) => entry.sub === member)).toMatchObject({ telegramId, telegramUsername: "tele_gram", }); + await pool.query('DELETE FROM "user" WHERE id = $1', [created.sub]); + expect((await readPeopleDirectory()).proposals).toContainEqual( + expect.objectContaining({ + personId: byUsername.sub, + sub: member, + reason: "telegram-username", + }), + ); } finally { await pool.query(`DELETE FROM identity_evidence WHERE subject = $1`, [subject]); - await pool.query('DELETE FROM "user" WHERE id = ANY($1)', [[member, created.sub]]); + await pool.query('DELETE FROM "user" WHERE id = ANY($1)', [ + [member, created.sub, byUsername.sub], + ]); } }); }, diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index 5dc2a11..9b2f3e6 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -186,6 +186,7 @@ export async function readPeopleDirectory(): Promise { name: person.name, ...appEmailFrom(person, entraEmails.get(person.id) ?? []), telegramId: telegramByUser.get(person.id)?.telegramId ?? null, + telegramUsername: telegramByUser.get(person.id)?.telegramUsername ?? null, })), ); From f48b750c34a50eba2cad13b5ac9fe82886e8dc9a Mon Sep 17 00:00:00 2001 From: Bianca Date: Wed, 28 Oct 2026 01:46:03 +0100 Subject: [PATCH 8/9] refactor(people)!: report identity evidence and leave matching to People The People directory now lists every account with the identity it proved (`verifiedEmail`, Microsoft 365 object ID, Telegram ID and username) and every preliminary person, and no longer computes link proposals. The backend matches its People records against this evidence with one rule; a Master Admin still approves a preliminary link here. BREAKING CHANGE: `GET /api/internal/people-roles` drops `proposals`, adds `verifiedEmail` and lists accounts without People roles. --- README.md | 2 +- src/auth/people-registration-matching.test.ts | 89 ------------------- src/auth/people-registration-matching.ts | 79 ---------------- src/auth/people-roles.integration.test.ts | 65 +++----------- src/auth/people-roles.ts | 39 +++----- 5 files changed, 25 insertions(+), 249 deletions(-) delete mode 100644 src/auth/people-registration-matching.test.ts delete mode 100644 src/auth/people-registration-matching.ts diff --git a/README.md b/README.md index c955efd..dd40291 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,7 @@ People can create a preliminary person in this IdP with only first and last name The same backend-only People endpoint accepts idempotent `create-person`, `update-person` and `link-person` operations. Creation/contact editing require `admin:access` and `people:write`; assignments retain the existing delegation checks. Linking requires Master Admin and atomically transfers the preliminary subject's current bound roles to an existing login account, preserving that account's other roles and provider accounts. Names and declared contacts never grant access by themselves. -Directory responses include stable `registrationId`, `provisional`, declared contacts and review proposals based on verified app email or Telegram identity. A declared Telegram username that matches a signed-in Telegram account is proposed as `telegram-username`, a weaker reason because usernames can change hands. Homonyms and conflicting identifiers are marked for review. The dashboard's Linking page is where a Master Admin approves or rejects the proposed identity; no automatic account merge occurs on login. Deploy this IdP and migration before the corresponding backend/admin changes. +Directory responses list every account with the identity it proved (`verifiedEmail`, Microsoft 365 object ID, Telegram ID and username) and every preliminary person with a stable `registrationId`, `provisional` and its declared contacts. Auth does not decide who is the same person: the backend matches its People records against this evidence (see the admin dashboard's `docs/people/linking.md`), and a Master Admin approves a preliminary link here. No automatic account merge occurs on login. Deploy this IdP and migration before the corresponding backend/admin changes. ## MCP server diff --git a/src/auth/people-registration-matching.test.ts b/src/auth/people-registration-matching.test.ts deleted file mode 100644 index 9c30f3e..0000000 --- a/src/auth/people-registration-matching.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { peopleRegistrationProposals } from "./people-registration-matching"; - -const person = { - id: "preliminary", - firstName: "Mario", - lastName: "Rossi", - email: "mario@example.org", - telegramId: "123", - telegramUsername: null, - linkedUserId: null, -}; -const account = { - sub: "login", - name: "Another name", - email: "MARIO@example.org", - emailVerified: true, - telegramId: null, - telegramUsername: null, -}; -describe("preliminary Auth identity proposals", () => { - it("proposes verified email and stable Telegram identities without linking", () => { - expect(peopleRegistrationProposals([person], [account])[0]).toMatchObject({ - personId: person.id, - sub: account.sub, - reason: "verified-email", - }); - expect( - peopleRegistrationProposals( - [person], - [{ ...account, emailVerified: false, telegramId: "123" }], - )[0]?.reason, - ).toBe("verified-telegram"); - }); - it("proposes a declared Telegram username, with or without @, as its own weaker reason", () => { - const declared = { ...person, telegramId: null, telegramUsername: "@Anna_PN" }; - const signedIn = { - ...account, - emailVerified: false, - telegramId: "77", - telegramUsername: "anna_pn", - }; - expect(peopleRegistrationProposals([declared], [signedIn])[0]).toMatchObject({ - personId: person.id, - sub: account.sub, - reason: "telegram-username", - }); - // A declared Telegram ID that disagrees still wins over the username. - expect( - peopleRegistrationProposals([{ ...declared, telegramId: "88" }], [signedIn])[0]?.reason, - ).toBe("conflicting-identities"); - expect( - peopleRegistrationProposals([declared], [{ ...signedIn, telegramUsername: "someone" }]), - ).toEqual([]); - // A verified Telegram ID or email outranks a username declared on another record. - expect( - peopleRegistrationProposals([{ ...person, telegramId: "77" }, declared], [signedIn]).map( - (proposal) => proposal.reason, - ), - ).toEqual(["verified-telegram"]); - }); - it("does not trust unverified mail and keeps homonyms as name-only reviews", () => { - expect(peopleRegistrationProposals([person], [{ ...account, emailVerified: false }])).toEqual( - [], - ); - expect( - peopleRegistrationProposals( - [person], - [{ ...account, email: "other@example.org", name: "Mario Rossi" }], - )[0]?.reason, - ).toBe("name-only"); - }); - it("flags ambiguous or disagreeing verified identifiers", () => { - expect( - peopleRegistrationProposals([person], [{ ...account, telegramId: "999" }])[0]?.reason, - ).toBe("conflicting-identities"); - expect( - peopleRegistrationProposals([person, { ...person, id: "duplicate" }], [account]).map( - (proposal) => proposal.reason, - ), - ).toEqual(["conflicting-identities", "conflicting-identities"]); - }); - it("does not propose preliminary subjects or accounts already claimed", () => { - expect(peopleRegistrationProposals([person], [{ ...account, sub: person.id }])).toEqual([]); - expect( - peopleRegistrationProposals([{ ...person, linkedUserId: account.sub }], [account]), - ).toEqual([]); - }); -}); diff --git a/src/auth/people-registration-matching.ts b/src/auth/people-registration-matching.ts deleted file mode 100644 index ef77c12..0000000 --- a/src/auth/people-registration-matching.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** Declared contacts produce review proposals; they never prove identity or grant roles. */ -export function peopleRegistrationProposals( - registrations: { - id: string; - firstName: string; - lastName: string; - email: string | null; - telegramId: string | null; - telegramUsername: string | null; - linkedUserId: string | null; - }[], - accounts: { - sub: string; - name: string; - email: string | null; - emailVerified: boolean; - telegramId: string | null; - telegramUsername: string | null; - }[], -) { - const lower = (value: string | null) => value?.trim().toLowerCase() || null; - // Usernames can change hands, so a match proposes the link but is not a verified identity. - const username = (value: string | null) => lower(value)?.replace(/^@/, "") || null; - const pending = registrations.filter((person) => !person.linkedUserId); - return accounts.flatMap((account) => { - if ( - registrations.some( - (person) => person.id === account.sub || person.linkedUserId === account.sub, - ) - ) - return []; - const strong = pending.filter( - (person) => - (account.emailVerified && - lower(account.email) && - lower(account.email) === lower(person.email)) || - (account.telegramId && account.telegramId === person.telegramId), - ); - const byUsername = strong.length - ? [] - : pending.filter( - (person) => - username(account.telegramUsername) && - username(account.telegramUsername) === username(person.telegramUsername), - ); - const candidates = strong.length - ? strong - : byUsername.length - ? byUsername - : pending.filter( - (person) => lower(`${person.firstName} ${person.lastName}`) === lower(account.name), - ); - return candidates.map((person) => ({ - personId: person.id, - sub: account.sub, - name: account.name, - email: account.email, - telegramId: account.telegramId, - reason: - strong.length > 1 || - byUsername.length > 1 || - ((strong.length || byUsername.length) && - account.telegramId && - person.telegramId && - account.telegramId !== person.telegramId) - ? ("conflicting-identities" as const) - : account.telegramId && account.telegramId === person.telegramId - ? ("verified-telegram" as const) - : account.emailVerified && - lower(account.email) && - lower(account.email) === lower(person.email) - ? ("verified-email" as const) - : username(account.telegramUsername) && - username(account.telegramUsername) === username(person.telegramUsername) - ? ("telegram-username" as const) - : ("name-only" as const), - })); - }); -} diff --git a/src/auth/people-roles.integration.test.ts b/src/auth/people-roles.integration.test.ts index 3dc0505..f59a6a5 100644 --- a/src/auth/people-roles.integration.test.ts +++ b/src/auth/people-roles.integration.test.ts @@ -187,9 +187,10 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( "INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) VALUES ($1, $2, $3, $4, $5, NOW())", [randomUUID(), randomUUID(), "google", "https://accounts.google.com", target], ); - expect((await readPeopleDirectory()).proposals).toContainEqual( - expect.objectContaining({ personId: created.sub, sub: target, reason: "verified-email" }), - ); + // Auth reports the verified address; People decides whether it is the same person. + expect( + (await readPeopleDirectory()).users.find((entry) => entry.sub === target), + ).toMatchObject({ verifiedEmail: input.email }); const link = { operation: "link-person" as const, id: randomUUID(), @@ -228,32 +229,8 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( } }); - it("keeps a Telegram sign-in's ID and match after its token has expired", async () => { + it("reports every account's Telegram identity, even after its sign-in token expired", async () => { const telegramId = String(Date.now()); - const created = await createPeoplePerson({ - operation: "create-person", - id: randomUUID(), - actor: hr, - firstName: "Tele", - lastName: "Gram", - email: "", - phone: "", - telegramId, - telegramUsername: "", - changes: [{ kind: "team", team, role: "member" }], - }); - const byUsername = await createPeoplePerson({ - operation: "create-person", - id: randomUUID(), - actor: hr, - firstName: "Only", - lastName: "Username", - email: "", - phone: "", - telegramId: "", - telegramUsername: "Tele_Gram", - changes: [{ kind: "team", team, role: "member" }], - }); const member = `people-telegram-${randomUUID().slice(0, 8)}`; const subject = `${member}-telegram`; try { @@ -272,36 +249,18 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( VALUES ('https://oauth.telegram.org', $1, 'telegram', '{}', NOW() - interval '1 day', $2, 'tele_gram')`, [subject, telegramId], ); - await assignRole(root, roles[`${team}-admin`]!, member); - const directory = await readPeopleDirectory(); - expect(directory.proposals).toContainEqual( - expect.objectContaining({ - personId: created.sub, - sub: member, - reason: "verified-telegram", - }), - ); - // The verified Telegram ID outranks the username declared on another record. - expect(directory.proposals?.some((proposal) => proposal.personId === byUsername.sub)).toBe( - false, - ); - expect(directory.users.find((entry) => entry.sub === member)).toMatchObject({ + // Listed without any People role: the backend matches its records against every account. + expect( + (await readPeopleDirectory()).users.find((entry) => entry.sub === member), + ).toMatchObject({ telegramId, telegramUsername: "tele_gram", + verifiedEmail: null, + roles: [], }); - await pool.query('DELETE FROM "user" WHERE id = $1', [created.sub]); - expect((await readPeopleDirectory()).proposals).toContainEqual( - expect.objectContaining({ - personId: byUsername.sub, - sub: member, - reason: "telegram-username", - }), - ); } finally { await pool.query(`DELETE FROM identity_evidence WHERE subject = $1`, [subject]); - await pool.query('DELETE FROM "user" WHERE id = ANY($1)', [ - [member, created.sub, byUsername.sub], - ]); + await pool.query('DELETE FROM "user" WHERE id = $1', [member]); } }); }, diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index 9b2f3e6..0d2023f 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -11,7 +11,6 @@ import { userRole, peopleRegistration, } from "../db/schema"; -import { peopleRegistrationProposals } from "./people-registration-matching"; import { appEmailFrom, contactEmails } from "./contact-email"; import { logAuthorizationDenial } from "./denial-log"; import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; @@ -34,10 +33,9 @@ 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. + * Every account, with the identity it proved, and every preliminary person not yet linked. + * People matches its records against these; Auth only reports the evidence. */ - proposals?: ReturnType; users: { sub: string; registrationId?: string; @@ -51,6 +49,8 @@ export type PeopleDirectory = { name: string; /** Their PoliNetwork address when they linked it, otherwise their sign-in address. */ email: string | null; + /** The address apps receive (`appEmail`) when it is verified: the only one safe to match on. */ + verifiedEmail: string | null; /** Their PoliNetwork Microsoft 365 object ID, when they linked that account. */ azureId: string | null; telegramId: string | null; @@ -68,8 +68,9 @@ export type PeopleDirectory = { }; /** - * 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. + * Accounts, the identity each proved and 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 { @@ -108,8 +109,7 @@ export async function readPeopleDirectory(): Promise { if (boundKeys.has(key) && !(dates[key] && dates[key] <= day)) dates[key] = day; since.set(row.userId, dates); } - // 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. + // People matches its Microsoft 365 members to accounts by this stable ID, not an address. const entra = await transaction .select({ userId: account.userId, @@ -139,12 +139,6 @@ export async function readPeopleDirectory(): Promise { const linkedPlaceholders = new Set( registrations.flatMap((person) => (person.linkedUserId ? [person.id] : [])), ); - const ids = new Set( - [...held.keys(), ...azureByUser.keys(), ...registrationBySub.keys()].filter( - (id) => !linkedPlaceholders.has(id), - ), - ); - if (!ids.size) return { roles, users: [] }; const people = await transaction .select({ id: user.id, @@ -179,24 +173,14 @@ export async function readPeopleDirectory(): Promise { .filter((row) => row.issuer === "https://oauth.telegram.org") .map((row) => [row.userId, row]), ); - const proposals = peopleRegistrationProposals( - registrations, - people.map((person) => ({ - sub: person.id, - name: person.name, - ...appEmailFrom(person, entraEmails.get(person.id) ?? []), - telegramId: telegramByUser.get(person.id)?.telegramId ?? null, - telegramUsername: telegramByUser.get(person.id)?.telegramUsername ?? null, - })), - ); - return { roles, - proposals, users: people - .filter((person) => ids.has(person.id)) + // A linked preliminary subject is reported through the account it was linked to. + .filter((person) => !linkedPlaceholders.has(person.id)) .map((person) => { const registration = registrationBySub.get(person.id); + const app = appEmailFrom(person, entraEmails.get(person.id) ?? []); return { ...(registration ? { @@ -216,6 +200,7 @@ export async function readPeopleDirectory(): Promise { registration && !registration.linkedUserId ? registration.email : (emails.get(person.id) ?? null), + verifiedEmail: app.emailVerified ? app.email : null, azureId: azureByUser.get(person.id) ?? null, telegramId: telegramByUser.get(person.id)?.telegramId ?? null, telegramUsername: telegramByUser.get(person.id)?.telegramUsername ?? null, From b4360ef7c8d413bd6ee70332d0182c61f86fc608 Mon Sep 17 00:00:00 2001 From: Bianca Date: Wed, 28 Oct 2026 12:04:44 +0100 Subject: [PATCH 9/9] feat(people): let Master Admin undo preliminary identity links --- README.md | 2 +- src/auth/people-registration-command.ts | 9 +++ src/auth/people-registration.ts | 78 ++++++++++++++++++++++- src/auth/people-roles.integration.test.ts | 44 ++++++++++++- src/routes/api/internal/people-roles.ts | 7 +- 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dd40291..adfdea2 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ Master Admin can bind a hand-made role to the admin dashboard's People register People can create a preliminary person in this IdP with only first and last name, optional email/phone/Telegram contacts, and bound admin/team roles. Migration `0024_people_registration.sql` stores the declared contacts separately from provider evidence. The subject holds its roles in Auth's existing role assignments but has no credentials, provider accounts or sessions, and is excluded from the login-account/signing picker. -The same backend-only People endpoint accepts idempotent `create-person`, `update-person` and `link-person` operations. Creation/contact editing require `admin:access` and `people:write`; assignments retain the existing delegation checks. Linking requires Master Admin and atomically transfers the preliminary subject's current bound roles to an existing login account, preserving that account's other roles and provider accounts. Names and declared contacts never grant access by themselves. +The same backend-only People endpoint accepts idempotent `create-person`, `update-person`, `link-person` and `unlink-person` operations. Creation/contact editing require `admin:access` and `people:write`; assignments retain the existing delegation checks. Linking requires Master Admin and atomically transfers the preliminary subject's current bound roles to an existing login account, preserving that account's other roles and provider accounts. Unlinking also requires Master Admin and undoes a mistaken link: the account's People-bound roles return to the preliminary person, which waits for its account again. Names and declared contacts never grant access by themselves. Directory responses list every account with the identity it proved (`verifiedEmail`, Microsoft 365 object ID, Telegram ID and username) and every preliminary person with a stable `registrationId`, `provisional` and its declared contacts. Auth does not decide who is the same person: the backend matches its People records against this evidence (see the admin dashboard's `docs/people/linking.md`), and a Master Admin approves a preliminary link here. No automatic account merge occurs on login. Deploy this IdP and migration before the corresponding backend/admin changes. diff --git a/src/auth/people-registration-command.ts b/src/auth/people-registration-command.ts index 9f98b05..9b19aa9 100644 --- a/src/auth/people-registration-command.ts +++ b/src/auth/people-registration-command.ts @@ -30,8 +30,17 @@ export const linkPeoplePersonSchema = z.object({ userId: z.string().min(1).max(200), reason: z.string().trim().min(1).max(500), }); +/** Undoes a mistaken link: the account gives the person's roles back and becomes unrelated again. */ +export const unlinkPeoplePersonSchema = z.object({ + operation: z.literal("unlink-person"), + id: z.uuid(), + actor: z.string().min(1).max(200), + personId: z.string().min(1).max(200), + reason: z.string().trim().min(1).max(500), +}); export type CreatePeoplePerson = z.infer; export type LinkPeoplePerson = z.infer; +export type UnlinkPeoplePerson = z.infer; export const updatePeoplePersonSchema = z.object({ operation: z.literal("update-person"), diff --git a/src/auth/people-registration.ts b/src/auth/people-registration.ts index d15f44b..8f75480 100644 --- a/src/auth/people-registration.ts +++ b/src/auth/people-registration.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, isNotNull } from "drizzle-orm"; import { db } from "../db/index"; import { account, peopleRegistration, rbacAuditEvent, role, user, userRole } from "../db/schema"; import { authorizationMutationLock } from "../db/security-lock"; @@ -7,6 +7,7 @@ import { peopleRoleChangeProblem } from "./people-binding"; import type { CreatePeoplePerson, LinkPeoplePerson, + UnlinkPeoplePerson, UpdatePeoplePerson, } from "./people-registration-command"; import { applyPeopleRoleCommand, canonical } from "./people-roles"; @@ -184,6 +185,81 @@ export async function linkPeoplePerson(input: LinkPeoplePerson): Promise<{ sub: }); } +/** + * Master approval undoes a link: every People-bound role the account holds goes back to the + * preliminary person, which waits for its account again. The account keeps its other roles. + * Only direct assignments are moved; inherited roles and provider accounts stay with the login. + */ +export async function unlinkPeoplePerson(input: UnlinkPeoplePerson): Promise<{ sub: string }> { + await refreshActor(input.actor); + return db.transaction(async (tx) => { + await tx.execute(authorizationMutationLock); + const receiptId = `people-unlink:${input.id}`; + const previous = await replay(tx, receiptId, input); + if (previous) return previous; + const { access } = await actorAccess(tx, input.actor); + if (!access.roles.includes(MASTER_ADMIN_ROLE_KEY)) + throw new RbacError(403, "A Master Admin must approve undoing this identity link."); + const [person] = await tx + .select() + .from(peopleRegistration) + .where(eq(peopleRegistration.id, input.personId)); + if (!person) throw new RbacError(404, "Preliminary person not found."); + if (!person.linkedUserId) { + const result = { sub: person.id }; + await tx.insert(rbacAuditEvent).values({ + id: receiptId, + actorId: input.actor, + operation: "people.unlink", + targetId: person.id, + before: input, + after: result, + }); + return result; + } + const linked = person.linkedUserId; + const assignments = await tx + .select({ roleId: userRole.roleId, assignedAt: userRole.assignedAt, key: role.key }) + .from(userRole) + .innerJoin(role, eq(userRole.roleId, role.id)) + .where(and(eq(userRole.userId, linked), isNotNull(role.peopleBinding))); + for (const assignment of assignments) + await tx + .insert(userRole) + .values({ + userId: person.id, + roleId: assignment.roleId, + assignedAt: assignment.assignedAt, + assignedBy: input.actor, + }) + .onConflictDoNothing(); + if (assignments.length) + await tx.delete(userRole).where( + and( + eq(userRole.userId, linked), + inArray( + userRole.roleId, + assignments.map((entry) => entry.roleId), + ), + ), + ); + await tx + .update(peopleRegistration) + .set({ linkedUserId: null }) + .where(eq(peopleRegistration.id, person.id)); + const result = { sub: person.id }; + await tx.insert(rbacAuditEvent).values({ + id: receiptId, + actorId: input.actor, + operation: "people.unlink", + targetId: person.id, + before: input, + after: { ...result, account: linked, roles: assignments.map((entry) => entry.key) }, + }); + return result; + }); +} + /** Contact corrections stay in Auth; none of these declared values are verification evidence. */ export async function updatePeoplePerson(input: UpdatePeoplePerson): Promise<{ sub: string }> { await refreshActor(input.actor); diff --git a/src/auth/people-roles.integration.test.ts b/src/auth/people-roles.integration.test.ts index f59a6a5..f905e36 100644 --- a/src/auth/people-roles.integration.test.ts +++ b/src/auth/people-roles.integration.test.ts @@ -30,7 +30,7 @@ vi.mock("./membership", () => ({ })); import { db } from "../db/index"; -import { createPeoplePerson, linkPeoplePerson } from "./people-registration"; +import { createPeoplePerson, linkPeoplePerson, unlinkPeoplePerson } from "./people-registration"; import { changePeopleRoles, readPeopleDirectory } from "./people-roles"; import { assignRole, saveRole, setRolePeopleBinding } from "./rbac-store"; @@ -224,6 +224,48 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( expect( (await pool.query("SELECT id FROM account WHERE user_id = $1", [target])).rows, ).toHaveLength(1); + + // A Master Admin undoes a mistaken link: the roles go back to the preliminary person. + const unlink = { + operation: "unlink-person" as const, + id: randomUUID(), + actor: hr, + personId: created.sub, + reason: "Linked to the wrong account", + }; + await expect(unlinkPeoplePerson(unlink)).rejects.toMatchObject({ status: 403 }); + expect(await unlinkPeoplePerson({ ...unlink, actor: root })).toMatchObject({ + sub: created.sub, + }); + expect(await unlinkPeoplePerson({ ...unlink, actor: root })).toMatchObject({ + sub: created.sub, + }); + await expect( + unlinkPeoplePerson({ ...unlink, actor: root, reason: "Changed command" }), + ).rejects.toMatchObject({ status: 409 }); + const alreadyUnlinked = { ...unlink, id: randomUUID(), actor: root }; + expect(await unlinkPeoplePerson(alreadyUnlinked)).toMatchObject({ sub: created.sub }); + await expect( + unlinkPeoplePerson({ ...alreadyUnlinked, reason: "Changed no-op command" }), + ).rejects.toMatchObject({ status: 409 }); + const directory = await readPeopleDirectory(); + expect(directory.users.find((entry) => entry.sub === created.sub)).toMatchObject({ + registrationId: created.sub, + provisional: true, + roles: expect.arrayContaining([`${team}-member`]), + }); + expect( + directory.users.find((entry) => entry.sub === target)?.registrationId, + ).toBeUndefined(); + // The account keeps only roles People doesn't own (an earlier test gave it one inheriting a team role). + expect( + ( + await pool.query( + "SELECT r.key FROM user_role ur JOIN role r ON r.id = ur.role_id WHERE ur.user_id = $1 AND r.people_binding IS NOT NULL", + [target], + ) + ).rows, + ).toEqual([]); } finally { await pool.query('DELETE FROM "user" WHERE id = $1', [created.sub]); } diff --git a/src/routes/api/internal/people-roles.ts b/src/routes/api/internal/people-roles.ts index f14316f..a340d6f 100644 --- a/src/routes/api/internal/people-roles.ts +++ b/src/routes/api/internal/people-roles.ts @@ -2,10 +2,12 @@ import { createPeoplePersonSchema, updatePeoplePersonSchema, linkPeoplePersonSchema, + unlinkPeoplePersonSchema, } from "@/auth/people-registration-command"; import { createPeoplePerson, linkPeoplePerson, + unlinkPeoplePerson, updatePeoplePerson, } from "@/auth/people-registration"; import { z } from "zod"; @@ -51,6 +53,7 @@ export const Route = createFileRoute("/api/internal/people-roles")({ createPeoplePersonSchema, updatePeoplePersonSchema, linkPeoplePersonSchema, + unlinkPeoplePersonSchema, peopleCommandSchema, ]) .safeParse(await request.json().catch(() => null)); @@ -64,7 +67,9 @@ export const Route = createFileRoute("/api/internal/people-roles")({ ? await createPeoplePerson(command) : command.operation === "update-person" ? await updatePeoplePerson(command) - : await linkPeoplePerson(command) + : command.operation === "link-person" + ? await linkPeoplePerson(command) + : await unlinkPeoplePerson(command) : await changePeopleRoles(command); return Response.json(result, { headers }); } catch (error) {