diff --git a/README.md b/README.md index 5df1e5f..adfdea2 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,20 @@ 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 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. + +### 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`, `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. + ## 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. @@ -371,7 +385,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/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/0024_people_registration.sql b/drizzle/0024_people_registration.sql new file mode 100644 index 0000000..75c59eb --- /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 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/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/0024_snapshot.json b/drizzle/meta/0024_snapshot.json new file mode 100644 index 0000000..d1ed319 --- /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": "cascade", + "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 364048d..87a601b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -162,6 +162,20 @@ "when": 1792827000000, "tag": "0022_idp_mcp", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "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/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 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/index.ts b/src/auth/index.ts index 8381283..9cdd88e 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -32,7 +32,13 @@ import { hasIdpPermission } from "./idp-access"; import { canAdministerIdp } from "./oidc-admin"; import { validateClientJwks } from "./client-jwks"; import { rawClientMutationPaths, refusedClientMutationPaths } from "./client-endpoints"; -import { apiScopes, isApiScope, OIDC_CLIENT_REFERENCE, parsePublicJwks } from "./oidc-clients"; +import { + apiScopes, + internalScopes, + isApiScope, + OIDC_CLIENT_REFERENCE, + parsePublicJwks, +} from "./oidc-clients"; import { isLinkOnlyProvider, standardIdTokenClaims } from "./policy"; import { providers } from "./providers"; import { clientIpOptions, oauthRateLimit, postgresRateLimitStorage } from "./rate-limit"; @@ -83,7 +89,7 @@ const serviceResources = { identifier: env.OAUTH_INTERNAL_RESOURCE_URI, accessTokenTtl: 300, - allowedScopes: ["idp:access:read"], + allowedScopes: internalScopes, signingAlgorithm: "EdDSA" as const, }, ] @@ -249,7 +255,7 @@ export const auth = betterAuth({ loginPage: "/", consentPage: "/consent", scopes: serviceOAuthEnabled - ? ["polinetwork:identity", ...backendScopes, "idp:access:read", MCP_SCOPE] + ? ["polinetwork:identity", ...backendScopes, ...internalScopes, MCP_SCOPE] : ["openid", "profile", "email", "polinetwork:identity", "offline_access", MCP_SCOPE], grantTypes: serviceOAuthEnabled ? ["authorization_code", "refresh_token", "client_credentials"] diff --git a/src/auth/mcp-diagnostics.test.ts b/src/auth/mcp-diagnostics.test.ts index f7b084a..095f568 100644 --- a/src/auth/mcp-diagnostics.test.ts +++ b/src/auth/mcp-diagnostics.test.ts @@ -188,6 +188,7 @@ function role(key: string, permissions: string[] = [], parents: string[] = []): permissions, parents, memberCount: 0, + peopleBinding: null, createdAt: null, updatedAt: null, }; diff --git a/src/auth/mcp-server.ts b/src/auth/mcp-server.ts index 7db3883..13d8f00 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 (PoliNetwork 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"), { diff --git a/src/auth/oidc-clients.ts b/src/auth/oidc-clients.ts index 282d9b7..de40197 100644 --- a/src/auth/oidc-clients.ts +++ b/src/auth/oidc-clients.ts @@ -122,8 +122,20 @@ export const apiScopes = [ description: "Pull every member's roles and permissions from this identity provider.", resource: "internal", }, + { + id: "idp:people:roles", + label: "People roles", + description: + "Give and take back the roles Master Admin bound to People, on behalf of a signed-in person who may change them.", + resource: "internal", + }, ] as const; +export type InternalScope = Extract<(typeof apiScopes)[number], { resource: "internal" }>["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..5795fd5 --- /dev/null +++ b/src/auth/people-binding.test.ts @@ -0,0 +1,234 @@ +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"; +import { planPeopleCommand } from "./people-role-command"; + +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(); + }); +}); + +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 new file mode 100644 index 0000000..20866db --- /dev/null +++ b/src/auth/people-binding.ts @@ -0,0 +1,118 @@ +// 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, + expandRoleKeys, + 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 "PoliNetwork admin"; + 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"; + }); + // 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)); + return missing.length + ? `This role grants permissions you do not hold (${missing.join(", ")}).` + : null; +} diff --git a/src/auth/people-registration-command.ts b/src/auth/people-registration-command.ts new file mode 100644 index 0000000..9b19aa9 --- /dev/null +++ b/src/auth/people-registration-command.ts @@ -0,0 +1,57 @@ +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), +}); +/** 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"), + 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.ts b/src/auth/people-registration.ts new file mode 100644 index 0000000..8f75480 --- /dev/null +++ b/src/auth/people-registration.ts @@ -0,0 +1,309 @@ +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"; +import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; +import { peopleRoleChangeProblem } from "./people-binding"; +import type { + CreatePeoplePerson, + LinkPeoplePerson, + UnlinkPeoplePerson, + 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; + }); +} + +/** + * 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); + 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-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..f905e36 --- /dev/null +++ b/src/auth/people-roles.integration.test.ts @@ -0,0 +1,309 @@ +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 { createPeoplePerson, linkPeoplePerson, unlinkPeoplePerson } from "./people-registration"; +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`], + 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], + ); + // 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(), + 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); + + // 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]); + } + }); + + it("reports every account's Telegram identity, even after its sign-in token expired", async () => { + const telegramId = String(Date.now()); + 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], + ); + // 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: [], + }); + } finally { + await pool.query(`DELETE FROM identity_evidence WHERE subject = $1`, [subject]); + await pool.query('DELETE FROM "user" WHERE id = $1', [member]); + } + }); + }, +); diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts new file mode 100644 index 0000000..0d2023f --- /dev/null +++ b/src/auth/people-roles.ts @@ -0,0 +1,372 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { db } from "../db/index"; +import { authorizationMutationLock } from "../db/security-lock"; +import { + account, + identityEvidence, + rbacAuditEvent, + role, + user, + userRole, + peopleRegistration, +} from "../db/schema"; +import { appEmailFrom, contactEmails } from "./contact-email"; +import { logAuthorizationDenial } from "./denial-log"; +import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; +import { + parsePeopleBinding, + 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 }[]; + /** + * 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. + */ + 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; + /** 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; + telegramUsername: string | null; + /** The bound roles they hold, sorted, including through a parent role. */ + roles: string[]; + /** Those of `roles` given to them directly: the only ones that can be taken back. */ + assigned: string[]; + /** + * When each of `roles` was first given (`YYYY-MM-DD`, UTC): the earliest assignment that + * confers it, directly or through a parent role. + */ + since: Record; + }[]; +}; + +/** + * 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 { + 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!, + })); + const registrations = await transaction.select().from(peopleRegistration); + + const assignments = await transaction + .select({ userId: userRole.userId, roleKey: role.key, assignedAt: userRole.assignedAt }) + .from(userRole) + .innerJoin(role, eq(userRole.roleId, role.id)) + .where(eq(role.managed, false)); + const assigned = new Map(); + for (const row of assignments) + 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 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); + } + // People matches its Microsoft 365 members to accounts by this stable ID, not an address. + const entra = await transaction + .select({ + userId: account.userId, + azureId: identityEvidence.externalId, + email: identityEvidence.email, + }) + .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 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 people = await transaction + .select({ + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + }) + .from(user) + .orderBy(user.id); + const emails = await contactEmails(transaction, people); + const telegram = await transaction + .select({ + userId: account.userId, + telegramId: identityEvidence.telegramId, + telegramUsername: identityEvidence.telegramUsername, + issuer: identityEvidence.issuer, + }) + .from(account) + .innerJoin( + identityEvidence, + and( + eq(account.issuer, identityEvidence.issuer), + eq(account.accountId, identityEvidence.subject), + eq(account.providerId, identityEvidence.providerId), + ), + ) + .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") + .map((row) => [row.userId, row]), + ); + return { + roles, + users: people + // 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 + ? { + 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), + 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, + 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" }, + ); +} + +type Reader = Pick; +type Transaction = Parameters[0]>[0]; + +/** Same command, same canonical form, whatever the key order jsonb stored it in. */ +export 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); +} + +/** + * 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 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.actor)); + if (!known) throw new RbacError(403, "The acting person has no account here."); + await refreshIdentityMembership(input.actor); + return transaction(async (tx) => { + const replayed = await replay(tx); + if (replayed) return replayed; + return applyPeopleRoleCommand(tx, input); + }); +} + +/** 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."); + + 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: randomUUID(), + actorId: input.actor, + operation, + targetId: step.target.id, + before, + after: { + ...after, + via: "people", + userId: input.sub, + reason: input.reason, + commandId: input.id, + }, + }); + } + 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/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/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/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..e7785c2 --- /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: "PoliNetwork admin" }, + { value: "member", label: "Team member" }, + { value: "lead", label: "Team Lead" }, +]; + +function bindingOf(kind: Kind, team: string) { + if (kind === "none") return null; + if (kind === "admin") return "admin"; + 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/people.ts b/src/db/people.ts new file mode 100644 index 0000000..3859d96 --- /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, { onDelete: "cascade" }), + 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/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/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/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/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 new file mode 100644 index 0000000..a340d6f --- /dev/null +++ b/src/routes/api/internal/people-roles.ts @@ -0,0 +1,84 @@ +import { + createPeoplePersonSchema, + updatePeoplePersonSchema, + linkPeoplePersonSchema, + unlinkPeoplePersonSchema, +} from "@/auth/people-registration-command"; +import { + createPeoplePerson, + linkPeoplePerson, + unlinkPeoplePerson, + 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"; +import { projectionForClient } from "@/auth/access-projections"; +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" }; + +/** 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 = z + .union([ + createPeoplePersonSchema, + updatePeoplePersonSchema, + linkPeoplePersonSchema, + unlinkPeoplePersonSchema, + peopleCommandSchema, + ]) + .safeParse(await request.json().catch(() => null)); + if (!parsed.success) + return Response.json({ error: "Invalid request" }, { status: 400, headers }); + try { + const command = parsed.data; + const result = + "operation" in command + ? command.operation === "create-person" + ? await createPeoplePerson(command) + : command.operation === "update-person" + ? await updatePeoplePerson(command) + : command.operation === "link-person" + ? await linkPeoplePerson(command) + : await unlinkPeoplePerson(command) + : await changePeopleRoles(command); + return Response.json(result, { 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 }); + } + }, + }, + }, +});