diff --git a/README.md b/README.md index 60d1b0b..a5c0e1b 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,15 @@ Future consumers should use authorization code with PKCE, validate token signatu Master Admin can bind a hand-made role to the admin dashboard's People register on the role's page (People register card): its holders are admins of the Telegram groups (`admin`), or members or the Lead of a team (`team::member`, `team::lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. -`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, everyone holding them by hand, and everyone with a PoliNetwork Microsoft 365 account (whose roles may be empty), with their name, directory email, Microsoft 365 object ID, Telegram ID and username, and which of those roles they were given directly; unlike the access snapshot it includes roles that grant no permission. `POST /api/internal/people-roles` (backend client, `idp:people:roles`) applies one People command `{ id, actor, sub, reason, changes }` on behalf of the dashboard user the backend authenticated, where each change names a desired state: `{ kind: "admin", active }` or `{ kind: "team", team, role: "member" | "lead" | null }`. This IdP plans the concrete assignments against its own role graph and applies all of them or none: a concept with no bound role stays People's own and is skipped; it refuses with 409 when the mapping is ambiguous (not exactly one bound role to give), or when the requested state would still be held through another role, rather than reporting a change that did not happen. The backend only names the actor; this IdP resolves the actor's own access and refuses every assignment, before any write, unless: the role and every bound role it inherits are allowed to the actor (admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`); the actor isn't giving themselves a role (giving one up is allowed); and, unless Master Admin, the actor holds every permission the role grants. The response is the person's bound roles after the command (`held`, `assigned`) and the bound role list. Each assignment is audited as `role.assign`/`role.unassign` with `via: "people"`, the reason and the command ID, and the command itself as `people.roles` under `people-command:`: repeating an ID with the same command, for example after a lost response, applies nothing again and returns the current roles, and repeating it with a different command is refused with 409. Grant `idp:people:roles` to the backend client on its application page to enable writes. +`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, everyone holding them by hand, and everyone with a PoliNetwork Microsoft 365 account (whose roles may be empty), with their name, directory email, Microsoft 365 object ID, Telegram ID and username, which of those roles they were given directly, and since when they hold each (`since`, the earliest assignment that confers it, so People shows when a role started without storing it); unlike the access snapshot it includes roles that grant no permission. `POST /api/internal/people-roles` (backend client, `idp:people:roles`) applies one People command `{ id, actor, sub, reason, changes }` on behalf of the dashboard user the backend authenticated, where each change names a desired state: `{ kind: "admin", active }` or `{ kind: "team", team, role: "member" | "lead" | null }`. This IdP plans the concrete assignments against its own role graph and applies all of them or none: a concept with no bound role stays People's own and is skipped; it refuses with 409 when the mapping is ambiguous (not exactly one bound role to give), or when the requested state would still be held through another role, rather than reporting a change that did not happen. The backend only names the actor; this IdP resolves the actor's own access and refuses every assignment, before any write, unless: the role and every bound role it inherits are allowed to the actor (admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`); the actor isn't giving themselves a role (giving one up is allowed); and, unless Master Admin, the actor holds every permission the role grants. The response is the person's bound roles after the command (`held`, `assigned`) and the bound role list. Each assignment is audited as `role.assign`/`role.unassign` with `via: "people"`, the reason and the command ID, and the command itself as `people.roles` under `people-command:`: repeating an ID with the same command, for example after a lost response, applies nothing again and returns the current roles, and repeating it with a different command is refused with 409. Grant `idp:people:roles` to the backend client on its application page to enable writes. + +### People before the first login + +People can create a preliminary person in this IdP with only first and last name, optional email/phone/Telegram contacts, and bound admin/team roles. Migration `0024_people_registration.sql` stores the declared contacts separately from provider evidence. The subject holds its roles in Auth's existing role assignments but has no credentials, provider accounts or sessions, and is excluded from the login-account/signing picker. + +The same backend-only People endpoint accepts idempotent `create-person`, `update-person` and `link-person` operations. Creation/contact editing require `admin:access` and `people:write`; assignments retain the existing delegation checks. Linking requires Master Admin and atomically transfers the preliminary subject's current bound roles to an existing login account, preserving that account's other roles and provider accounts. Names and declared contacts never grant access by themselves. + +Directory responses include stable `registrationId`, `provisional`, declared contacts and review proposals based on verified app email or Telegram identity. Homonyms and conflicting identifiers are marked for review. The dashboard's Linking page is where a Master Admin approves or rejects the proposed identity; no automatic account merge occurs on login. Deploy this IdP and migration before the corresponding backend/admin changes. ## MCP server diff --git a/drizzle/0024_people_registration.sql b/drizzle/0024_people_registration.sql new file mode 100644 index 0000000..a469d40 --- /dev/null +++ b/drizzle/0024_people_registration.sql @@ -0,0 +1,16 @@ +CREATE TABLE "people_registration" ( + "id" text PRIMARY KEY NOT NULL, + "first_name" text NOT NULL, + "last_name" text NOT NULL, + "email" text, + "phone" text, + "telegram_id" text, + "telegram_username" text, + "linked_user_id" text, + "created_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "people_registration" ADD CONSTRAINT "people_registration_id_user_id_fk" FOREIGN KEY ("id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "people_registration" ADD CONSTRAINT "people_registration_linked_user_id_user_id_fk" FOREIGN KEY ("linked_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "people_registration_linked_user_idx" ON "people_registration" USING btree ("linked_user_id"); \ No newline at end of file diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json new file mode 100644 index 0000000..0ef1086 --- /dev/null +++ b/drizzle/meta/0024_snapshot.json @@ -0,0 +1,2595 @@ +{ + "id": "65141c9c-e790-467b-bd87-381ff9d9e561", + "prevId": "6e6dc4d1-22d0-44ab-a009-fd1f763786a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_issuer_subject_uidx": { + "name": "account_issuer_subject_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_authorizationCodeId_idx": { + "name": "oauthAccessToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_dark": { + "name": "icon_dark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClientResource_clientId_resourceId_uidx": { + "name": "oauthClientResource_clientId_resourceId_uidx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_clientId_idx": { + "name": "oauthClientResource_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_resourceId_idx": { + "name": "oauthClientResource_resourceId_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["identifier"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_authorizationCodeId_idx": { + "name": "oauthRefreshToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "passkey_prompt_declined_at": { + "name": "passkey_prompt_declined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_evidence": { + "name": "identity_evidence", + "schema": "", + "columns": { + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "states": { + "name": "states", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "identity_evidence_telegram_id_uidx": { + "name": "identity_evidence_telegram_id_uidx", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_evidence\".\"telegram_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "identity_evidence_issuer_subject_pk": { + "name": "identity_evidence_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.student_verification_challenge": { + "name": "student_verification_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "studentVerificationChallenge_email_uidx": { + "name": "studentVerificationChallenge_email_uidx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "studentVerificationChallenge_expiresAt_idx": { + "name": "studentVerificationChallenge_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "student_verification_challenge_user_id_user_id_fk": { + "name": "student_verification_challenge_user_id_user_id_fk", + "tableFrom": "student_verification_challenge", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission": { + "name": "permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_key_uidx": { + "name": "permission_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_implication": { + "name": "permission_implication", + "schema": "", + "columns": { + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "implied_permission_id": { + "name": "implied_permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "permissionImplication_implied_idx": { + "name": "permissionImplication_implied_idx", + "columns": [ + { + "expression": "implied_permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_implication_permission_id_permission_id_fk": { + "name": "permission_implication_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_implication_implied_permission_id_permission_id_fk": { + "name": "permission_implication_implied_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["implied_permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "permission_implication_permission_id_implied_permission_id_pk": { + "name": "permission_implication_permission_id_implied_permission_id_pk", + "columns": ["permission_id", "implied_permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_audit_event": { + "name": "rbac_audit_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_state": { + "name": "source_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "people_binding": { + "name": "people_binding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_key_uidx": { + "name": "role_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "role_people_binding_check": { + "name": "role_people_binding_check", + "value": "\"role\".\"people_binding\" is null or (not \"role\".\"managed\" and \"role\".\"people_binding\" ~ '^(admin|team:[a-z0-9-]{1,32}:(member|lead))$')" + } + }, + "isRLSEnabled": false + }, + "public.role_parent": { + "name": "role_parent", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_role_id": { + "name": "parent_role_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "roleParent_parent_idx": { + "name": "roleParent_parent_idx", + "columns": [ + { + "expression": "parent_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_parent_role_id_role_id_fk": { + "name": "role_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_parent_parent_role_id_role_id_fk": { + "name": "role_parent_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["parent_role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_parent_role_id_parent_role_id_pk": { + "name": "role_parent_role_id_parent_role_id_pk", + "columns": ["role_id", "parent_role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permission": { + "name": "role_permission", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rolePermission_permission_idx": { + "name": "rolePermission_permission_idx", + "columns": [ + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permission_permission_id_permission_id_fk": { + "name": "role_permission_permission_id_permission_id_fk", + "tableFrom": "role_permission", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permission_role_id_permission_id_pk": { + "name": "role_permission_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_role": { + "name": "user_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "userRole_role_idx": { + "name": "userRole_role_idx", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_role_user_id_user_id_fk": { + "name": "user_role_user_id_user_id_fk", + "tableFrom": "user_role", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_role_role_id_role_id_fk": { + "name": "user_role_role_id_role_id_fk", + "tableFrom": "user_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_role_user_id_role_id_pk": { + "name": "user_role_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_outbox": { + "name": "access_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "access_outbox_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "projection": { + "name": "projection", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_snapshot_state": { + "name": "access_snapshot_state", + "schema": "", + "columns": { + "projection": { + "name": "projection", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entra_group_observation": { + "name": "entra_group_observation", + "schema": "", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "members": { + "name": "members", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people_registration": { + "name": "people_registration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_id": { + "name": "telegram_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_user_id": { + "name": "linked_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "people_registration_linked_user_idx": { + "name": "people_registration_linked_user_idx", + "columns": [ + { + "expression": "linked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "people_registration_id_user_id_fk": { + "name": "people_registration_id_user_id_fk", + "tableFrom": "people_registration", + "tableTo": "user", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "people_registration_linked_user_id_user_id_fk": { + "name": "people_registration_linked_user_id_user_id_fk", + "tableFrom": "people_registration", + "tableTo": "user", + "columnsFrom": ["linked_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9dc337d..87a601b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1792927355527, "tag": "0023_role_people_binding", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1793093534908, + "tag": "0024_people_registration", + "breakpoints": true } ] } diff --git a/src/auth/people-registration-command.ts b/src/auth/people-registration-command.ts new file mode 100644 index 0000000..9f98b05 --- /dev/null +++ b/src/auth/people-registration-command.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { peopleCommandSchema } from "./people-role-command"; + +const contact = (max: number) => z.string().trim().max(max).default(""); +export const createPeoplePersonSchema = z + .object({ + operation: z.literal("create-person"), + id: z.uuid(), + actor: z.string().min(1).max(200), + firstName: z.string().trim().min(1).max(80), + lastName: z.string().trim().min(1).max(80), + email: z.union([z.literal(""), z.email().max(254)]).default(""), + phone: contact(40), + telegramId: z.union([z.literal(""), z.string().regex(/^[1-9]\d{0,19}$/)]).default(""), + telegramUsername: contact(32).refine((value) => !value || /^[A-Za-z0-9_]{5,32}$/.test(value)), + changes: z.array(peopleCommandSchema.shape.changes.element).max(64), + }) + .superRefine((input, ctx) => { + const keys = input.changes.map((change) => + change.kind === "admin" ? "admin" : `team:${change.team}`, + ); + if (new Set(keys).size !== keys.length) + ctx.addIssue({ code: "custom", message: "Each role concept may appear only once" }); + }); +export const linkPeoplePersonSchema = z.object({ + operation: z.literal("link-person"), + id: z.uuid(), + actor: z.string().min(1).max(200), + personId: z.string().min(1).max(200), + userId: z.string().min(1).max(200), + reason: z.string().trim().min(1).max(500), +}); +export type CreatePeoplePerson = z.infer; +export type LinkPeoplePerson = z.infer; + +export const updatePeoplePersonSchema = z.object({ + operation: z.literal("update-person"), + id: z.uuid(), + actor: createPeoplePersonSchema.shape.actor, + personId: z.string().min(1).max(200), + firstName: createPeoplePersonSchema.shape.firstName, + lastName: createPeoplePersonSchema.shape.lastName, + email: createPeoplePersonSchema.shape.email, + phone: createPeoplePersonSchema.shape.phone, + telegramId: createPeoplePersonSchema.shape.telegramId, + telegramUsername: createPeoplePersonSchema.shape.telegramUsername, +}); +export type UpdatePeoplePerson = z.infer; diff --git a/src/auth/people-registration-matching.test.ts b/src/auth/people-registration-matching.test.ts new file mode 100644 index 0000000..6452c4e --- /dev/null +++ b/src/auth/people-registration-matching.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vite-plus/test"; +import { peopleRegistrationProposals } from "./people-registration-matching"; + +const person = { + id: "preliminary", + firstName: "Mario", + lastName: "Rossi", + email: "mario@example.org", + telegramId: "123", + linkedUserId: null, +}; +const account = { + sub: "login", + name: "Another name", + email: "MARIO@example.org", + emailVerified: true, + telegramId: null, +}; +describe("preliminary Auth identity proposals", () => { + it("proposes verified email and stable Telegram identities without linking", () => { + expect(peopleRegistrationProposals([person], [account])[0]).toMatchObject({ + personId: person.id, + sub: account.sub, + reason: "verified-email", + }); + expect( + peopleRegistrationProposals( + [person], + [{ ...account, emailVerified: false, telegramId: "123" }], + )[0]?.reason, + ).toBe("verified-telegram"); + }); + it("does not trust unverified mail and keeps homonyms as name-only reviews", () => { + expect(peopleRegistrationProposals([person], [{ ...account, emailVerified: false }])).toEqual( + [], + ); + expect( + peopleRegistrationProposals( + [person], + [{ ...account, email: "other@example.org", name: "Mario Rossi" }], + )[0]?.reason, + ).toBe("name-only"); + }); + it("flags ambiguous or disagreeing verified identifiers", () => { + expect( + peopleRegistrationProposals([person], [{ ...account, telegramId: "999" }])[0]?.reason, + ).toBe("conflicting-identities"); + expect( + peopleRegistrationProposals([person, { ...person, id: "duplicate" }], [account]).map( + (proposal) => proposal.reason, + ), + ).toEqual(["conflicting-identities", "conflicting-identities"]); + }); + it("does not propose preliminary subjects or accounts already claimed", () => { + expect(peopleRegistrationProposals([person], [{ ...account, sub: person.id }])).toEqual([]); + expect( + peopleRegistrationProposals([{ ...person, linkedUserId: account.sub }], [account]), + ).toEqual([]); + }); +}); diff --git a/src/auth/people-registration-matching.ts b/src/auth/people-registration-matching.ts new file mode 100644 index 0000000..46a7965 --- /dev/null +++ b/src/auth/people-registration-matching.ts @@ -0,0 +1,62 @@ +/** Declared contacts produce review proposals; they never prove identity or grant roles. */ +export function peopleRegistrationProposals( + registrations: { + id: string; + firstName: string; + lastName: string; + email: string | null; + telegramId: string | null; + linkedUserId: string | null; + }[], + accounts: { + sub: string; + name: string; + email: string | null; + emailVerified: boolean; + telegramId: string | null; + }[], +) { + const lower = (value: string | null) => value?.trim().toLowerCase() || null; + const pending = registrations.filter((person) => !person.linkedUserId); + return accounts.flatMap((account) => { + if ( + registrations.some( + (person) => person.id === account.sub || person.linkedUserId === account.sub, + ) + ) + return []; + const strong = pending.filter( + (person) => + (account.emailVerified && + lower(account.email) && + lower(account.email) === lower(person.email)) || + (account.telegramId && account.telegramId === person.telegramId), + ); + const candidates = strong.length + ? strong + : pending.filter( + (person) => lower(`${person.firstName} ${person.lastName}`) === lower(account.name), + ); + return candidates.map((person) => ({ + personId: person.id, + sub: account.sub, + name: account.name, + email: account.email, + telegramId: account.telegramId, + reason: + strong.length > 1 || + (strong.length && + account.telegramId && + person.telegramId && + account.telegramId !== person.telegramId) + ? ("conflicting-identities" as const) + : account.telegramId && account.telegramId === person.telegramId + ? ("verified-telegram" as const) + : account.emailVerified && + lower(account.email) && + lower(account.email) === lower(person.email) + ? ("verified-email" as const) + : ("name-only" as const), + })); + }); +} diff --git a/src/auth/people-registration.ts b/src/auth/people-registration.ts new file mode 100644 index 0000000..d15f44b --- /dev/null +++ b/src/auth/people-registration.ts @@ -0,0 +1,233 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { db } from "../db/index"; +import { account, peopleRegistration, rbacAuditEvent, role, user, userRole } from "../db/schema"; +import { authorizationMutationLock } from "../db/security-lock"; +import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; +import { peopleRoleChangeProblem } from "./people-binding"; +import type { + CreatePeoplePerson, + LinkPeoplePerson, + UpdatePeoplePerson, +} from "./people-registration-command"; +import { applyPeopleRoleCommand, canonical } from "./people-roles"; +import { MASTER_ADMIN_ROLE_KEY, resolveAccess } from "./rbac"; +import { assignedRoleKeys, RbacError, readCatalog } from "./rbac-store"; + +type Transaction = Parameters[0]>[0]; +async function actorAccess(tx: Transaction, actor: string) { + const catalog = await readCatalog(tx); + const subject = await readIdentitySubject(actor, tx); + const access = resolveAccess(catalog, [ + ...(await assignedRoleKeys(actor, catalog, tx)), + ...subject.roleKeys, + ]); + return { catalog, access }; +} +async function refreshActor(actor: string) { + const [known] = await db.select({ id: user.id }).from(user).where(eq(user.id, actor)); + if (!known) throw new RbacError(403, "The acting person has no account here."); + await refreshIdentityMembership(actor); +} +async function replay(tx: Transaction, id: string, input: unknown) { + const [receipt] = await tx.select().from(rbacAuditEvent).where(eq(rbacAuditEvent.id, id)); + if (!receipt) return null; + if (canonical(receipt.before) !== canonical(input)) + throw new RbacError(409, "Command ID already used with different input."); + return receipt.after as { sub: string }; +} + +/** A non-login subject holds real Auth roles; declared contacts never enter identity evidence. */ +export async function createPeoplePerson(input: CreatePeoplePerson): Promise<{ sub: string }> { + await refreshActor(input.actor); + return db.transaction(async (tx) => { + await tx.execute(authorizationMutationLock); + const receiptId = `people-person:${input.id}`; + const previous = await replay(tx, receiptId, input); + if (previous) return previous; + const { catalog, access } = await actorAccess(tx, input.actor); + if ( + !access.permissions.includes("admin:access") || + !access.permissions.includes("people:write") + ) + throw new RbacError(403, "Creating a person needs people:write and admin:access."); + for (const change of input.changes) { + const binding = change.kind === "admin" ? "admin" : `team:${change.team}:${change.role}`; + if (!catalog.roles.some((entry) => entry.peopleBinding === binding)) + throw new RbacError(409, `Bind an Auth role for ${binding} before assigning it.`); + } + const sub = `people:${input.id}`; + await tx.insert(user).values({ + id: sub, + name: `${input.firstName} ${input.lastName}`, + email: `people.${input.id}@identity.invalid`, + emailVerified: false, + }); + await tx.insert(peopleRegistration).values({ + id: sub, + firstName: input.firstName, + lastName: input.lastName, + email: input.email || null, + phone: input.phone || null, + telegramId: input.telegramId || null, + telegramUsername: input.telegramUsername || null, + createdBy: input.actor, + }); + if (input.changes.length) + await applyPeopleRoleCommand(tx, { + id: input.id, + actor: input.actor, + sub, + changes: input.changes, + reason: "Created preliminary person in Auth", + }); + const result = { sub }; + await tx.insert(rbacAuditEvent).values({ + id: receiptId, + actorId: input.actor, + operation: "people.create", + targetId: sub, + before: input, + after: result, + }); + return result; + }); +} + +/** Master approval transfers only bound roles, never credentials, sessions or provider accounts. */ +export async function linkPeoplePerson(input: LinkPeoplePerson): Promise<{ sub: string }> { + await refreshActor(input.actor); + return db.transaction(async (tx) => { + await tx.execute(authorizationMutationLock); + const receiptId = `people-link:${input.id}`; + const previous = await replay(tx, receiptId, input); + if (previous) return previous; + const { catalog, access } = await actorAccess(tx, input.actor); + if (!access.roles.includes(MASTER_ADMIN_ROLE_KEY)) + throw new RbacError(403, "A Master Admin must approve this identity link."); + const [person] = await tx + .select() + .from(peopleRegistration) + .where(eq(peopleRegistration.id, input.personId)); + if (!person) throw new RbacError(404, "Preliminary person not found."); + if (person.linkedUserId === input.userId) return { sub: input.userId }; + if (person.linkedUserId) throw new RbacError(409, "This person already has a linked account."); + const [target] = await tx + .select({ id: account.id }) + .from(account) + .where(eq(account.userId, input.userId)) + .limit(1); + const [claimed] = await tx + .select({ id: peopleRegistration.id }) + .from(peopleRegistration) + .where(eq(peopleRegistration.linkedUserId, input.userId)); + const [preliminaryTarget] = await tx + .select({ id: peopleRegistration.id }) + .from(peopleRegistration) + .where(eq(peopleRegistration.id, input.userId)); + if (!target || claimed || preliminaryTarget) + throw new RbacError( + 409, + "Choose an existing login account that is not linked to another preliminary person.", + ); + const assignments = await tx + .select({ roleId: userRole.roleId, assignedAt: userRole.assignedAt, key: role.key }) + .from(userRole) + .innerJoin(role, eq(userRole.roleId, role.id)) + .where(eq(userRole.userId, input.personId)); + for (const assignment of assignments) { + const assigned = catalog.roles.find((entry) => entry.key === assignment.key); + if (!assigned) throw new RbacError(409, "An assigned role no longer exists."); + const problem = peopleRoleChangeProblem({ + catalog, + actorId: input.actor, + actor: access, + targetId: input.userId, + role: assigned, + action: "assign", + }); + if (problem) throw new RbacError(403, problem); + } + for (const assignment of assignments) + await tx + .insert(userRole) + .values({ + userId: input.userId, + roleId: assignment.roleId, + assignedAt: assignment.assignedAt, + assignedBy: input.actor, + }) + .onConflictDoNothing(); + if (assignments.length) + await tx.delete(userRole).where( + and( + eq(userRole.userId, input.personId), + inArray( + userRole.roleId, + assignments.map((entry) => entry.roleId), + ), + ), + ); + await tx + .update(peopleRegistration) + .set({ linkedUserId: input.userId }) + .where(eq(peopleRegistration.id, input.personId)); + const result = { sub: input.userId }; + await tx.insert(rbacAuditEvent).values({ + id: receiptId, + actorId: input.actor, + operation: "people.link", + targetId: input.personId, + before: input, + after: { ...result, roles: assignments.map((entry) => entry.key) }, + }); + return result; + }); +} + +/** Contact corrections stay in Auth; none of these declared values are verification evidence. */ +export async function updatePeoplePerson(input: UpdatePeoplePerson): Promise<{ sub: string }> { + await refreshActor(input.actor); + return db.transaction(async (tx) => { + await tx.execute(authorizationMutationLock); + const receiptId = `people-update:${input.id}`; + const previous = await replay(tx, receiptId, input); + if (previous) return previous; + const { access } = await actorAccess(tx, input.actor); + if ( + !access.permissions.includes("admin:access") || + !access.permissions.includes("people:write") + ) + throw new RbacError(403, "Changing contacts needs people:write and admin:access."); + const [person] = await tx + .select() + .from(peopleRegistration) + .where(eq(peopleRegistration.id, input.personId)); + if (!person) throw new RbacError(404, "Auth person not found."); + await tx + .update(peopleRegistration) + .set({ + firstName: input.firstName, + lastName: input.lastName, + email: input.email || null, + phone: input.phone || null, + telegramId: input.telegramId || null, + telegramUsername: input.telegramUsername || null, + }) + .where(eq(peopleRegistration.id, input.personId)); + if (!person.linkedUserId) + await tx + .update(user) + .set({ name: `${input.firstName} ${input.lastName}` }) + .where(eq(user.id, input.personId)); + const result = { sub: person.linkedUserId ?? person.id }; + await tx.insert(rbacAuditEvent).values({ + id: receiptId, + actorId: input.actor, + operation: "people.update", + targetId: person.id, + before: input, + after: result, + }); + return result; + }); +} diff --git a/src/auth/people-roles.integration.test.ts b/src/auth/people-roles.integration.test.ts index 14d048e..46ec9ec 100644 --- a/src/auth/people-roles.integration.test.ts +++ b/src/auth/people-roles.integration.test.ts @@ -30,6 +30,7 @@ vi.mock("./membership", () => ({ })); import { db } from "../db/index"; +import { createPeoplePerson, linkPeoplePerson } from "./people-registration"; import { changePeopleRoles, readPeopleDirectory } from "./people-roles"; import { assignRole, saveRole, setRolePeopleBinding } from "./rbac-store"; @@ -143,7 +144,88 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)( expect(directory.users.find((entry) => entry.sub === target)).toMatchObject({ roles: [`${team}-member`], assigned: [`${team}-member`], + since: { [`${team}-member`]: new Date().toISOString().slice(0, 10) }, }); }); + it("creates an Auth person before login and requires Master approval to bind the real account", async () => { + const id = randomUUID(); + const input = { + operation: "create-person" as const, + id, + actor: hr, + firstName: "Pre", + lastName: "Login", + email: "pre-login@example.org", + phone: "", + telegramId: "", + telegramUsername: "", + changes: [{ kind: "team" as const, team, role: "member" as const }], + }; + const created = await createPeoplePerson(input); + try { + expect(await createPeoplePerson(input)).toEqual(created); + await expect( + createPeoplePerson({ ...input, firstName: "Different" }), + ).rejects.toMatchObject({ status: 409 }); + expect( + (await pool.query("SELECT id FROM account WHERE user_id = $1", [created.sub])).rows, + ).toEqual([]); + const preliminary = (await readPeopleDirectory()).users.find( + (entry) => entry.sub === created.sub, + ); + expect(preliminary).toMatchObject({ + provisional: true, + registrationId: created.sub, + roles: [`${team}-member`], + email: input.email, + }); + await pool.query('UPDATE "user" SET email = $1, email_verified = true WHERE id = $2', [ + input.email, + target, + ]); + await pool.query( + "INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) VALUES ($1, $2, $3, $4, $5, NOW())", + [randomUUID(), randomUUID(), "google", "https://accounts.google.com", target], + ); + expect((await readPeopleDirectory()).proposals).toContainEqual( + expect.objectContaining({ personId: created.sub, sub: target, reason: "verified-email" }), + ); + const link = { + operation: "link-person" as const, + id: randomUUID(), + actor: hr, + personId: created.sub, + userId: target, + reason: "Checked verified identity", + }; + await expect(linkPeoplePerson(link)).rejects.toMatchObject({ status: 403 }); + expect( + (await readPeopleDirectory()).users.find((entry) => entry.sub === created.sub) + ?.provisional, + ).toBe(true); + const approved = { ...link, actor: root }; + expect(await linkPeoplePerson(approved)).toMatchObject({ sub: target }); + expect(await linkPeoplePerson(approved)).toMatchObject({ sub: target }); + expect(await linkPeoplePerson({ ...approved, id: randomUUID() })).toMatchObject({ + sub: target, + }); + expect( + (await readPeopleDirectory()).users.find((entry) => entry.sub === target), + ).toMatchObject({ + registrationId: created.sub, + provisional: false, + previousSub: created.sub, + roles: expect.arrayContaining([`${team}-member`]), + }); + expect((await readPeopleDirectory()).users.some((entry) => entry.sub === created.sub)).toBe( + false, + ); + expect( + (await pool.query("SELECT id FROM account WHERE user_id = $1", [target])).rows, + ).toHaveLength(1); + } finally { + await pool.query('DELETE FROM "user" WHERE id = $1', [created.sub]); + } + }); }, ); diff --git a/src/auth/people-roles.ts b/src/auth/people-roles.ts index 830bb8b..0869296 100644 --- a/src/auth/people-roles.ts +++ b/src/auth/people-roles.ts @@ -1,9 +1,18 @@ import { randomUUID } from "node:crypto"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db } from "../db/index"; import { authorizationMutationLock } from "../db/security-lock"; -import { account, identityEvidence, rbacAuditEvent, role, user, userRole } from "../db/schema"; -import { contactEmails } from "./contact-email"; +import { + account, + identityEvidence, + rbacAuditEvent, + role, + user, + userRole, + peopleRegistration, +} from "../db/schema"; +import { peopleRegistrationProposals } from "./people-registration-matching"; +import { appEmail, contactEmails } from "./contact-email"; import { logAuthorizationDenial } from "./denial-log"; import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; import { @@ -28,8 +37,17 @@ export type PeopleDirectory = { * Everyone holding at least one of those roles, by hand or through a parent role, and * everyone with a PoliNetwork Microsoft 365 account, whose `roles` may be empty. */ + proposals?: ReturnType; users: { sub: string; + registrationId?: string; + provisional?: boolean; + previousSub?: string; + firstName?: string; + lastName?: string; + phone?: string | null; + declaredTelegramId?: string | null; + declaredTelegramUsername?: string | null; name: string; /** Their PoliNetwork address when they linked it, otherwise their sign-in address. */ email: string | null; @@ -41,6 +59,11 @@ export type PeopleDirectory = { roles: string[]; /** Those of `roles` given to them directly: the only ones that can be taken back. */ assigned: string[]; + /** + * When each of `roles` was first given (`YYYY-MM-DD`, UTC): the earliest assignment that + * confers it, directly or through a parent role. + */ + since: Record; }[]; }; @@ -62,10 +85,10 @@ export async function readPeopleDirectory(): Promise { name: entry.name, binding: entry.peopleBinding!, })); - if (!bound.length) return { roles, users: [] }; + const registrations = await transaction.select().from(peopleRegistration); const assignments = await transaction - .select({ userId: userRole.userId, roleKey: role.key }) + .select({ userId: userRole.userId, roleKey: role.key, assignedAt: userRole.assignedAt }) .from(userRole) .innerJoin(role, eq(userRole.roleId, role.id)) .where(eq(role.managed, false)); @@ -77,6 +100,14 @@ export async function readPeopleDirectory(): Promise { const roles = expandRoleKeys(catalog, keys).filter((key) => boundKeys.has(key)); if (roles.length) held.set(userId, roles); } + const since = new Map>(); + for (const row of assignments) { + const day = row.assignedAt.toISOString().slice(0, 10); + const dates = since.get(row.userId) ?? {}; + for (const key of expandRoleKeys(catalog, [row.roleKey])) + if (boundKeys.has(key) && !(dates[key] && dates[key] <= day)) dates[key] = day; + since.set(row.userId, dates); + } // Also every PoliNetwork Microsoft 365 account, bound role or not: People matches its // Microsoft 365 members to linked accounts by that stable ID instead of an address. const entra = await transaction @@ -94,12 +125,22 @@ export async function readPeopleDirectory(): Promise { const azureByUser = new Map( entra.flatMap((row) => (row.azureId ? [[row.userId, row.azureId] as const] : [])), ); - const ids = [...new Set([...held.keys(), ...azureByUser.keys()])]; + const ids = [ + ...new Set([ + ...held.keys(), + ...azureByUser.keys(), + ...registrations.map((person) => person.linkedUserId ?? person.id), + ]), + ].filter((id) => !registrations.some((person) => person.id === id && person.linkedUserId)); if (!ids.length) return { roles, users: [] }; const people = await transaction - .select({ id: user.id, name: user.name, email: user.email }) + .select({ + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + }) .from(user) - .where(inArray(user.id, ids)) .orderBy(user.id); const emails = await contactEmails(transaction, people); const telegram = await transaction @@ -107,6 +148,8 @@ export async function readPeopleDirectory(): Promise { userId: account.userId, telegramId: identityEvidence.telegramId, telegramUsername: identityEvidence.telegramUsername, + issuer: identityEvidence.issuer, + validUntil: identityEvidence.validUntil, }) .from(account) .innerJoin( @@ -117,21 +160,64 @@ export async function readPeopleDirectory(): Promise { eq(account.providerId, identityEvidence.providerId), ), ) - .where(and(inArray(account.userId, ids), eq(account.providerId, "telegram"))); - const telegramByUser = new Map(telegram.map((row) => [row.userId, row])); - - return { - roles, - users: people.map((person) => ({ + .where(eq(account.providerId, "telegram")); + const telegramByUser = new Map( + telegram + .filter( + (row) => row.issuer === "https://oauth.telegram.org" && row.validUntil > new Date(), + ) + .map((row) => [row.userId, row]), + ); + const verifiedEmails = await Promise.all( + people.map((person) => appEmail(transaction, person)), + ); + const proposals = peopleRegistrationProposals( + registrations, + people.map((person, index) => ({ sub: person.id, name: person.name, - email: emails.get(person.id) ?? null, - azureId: azureByUser.get(person.id) ?? null, + email: verifiedEmails[index]!.email, + emailVerified: verifiedEmails[index]!.emailVerified, telegramId: telegramByUser.get(person.id)?.telegramId ?? null, - telegramUsername: telegramByUser.get(person.id)?.telegramUsername ?? null, - roles: held.get(person.id) ?? [], - assigned: (assigned.get(person.id) ?? []).filter((key) => boundKeys.has(key)).sort(), })), + ); + + return { + roles, + proposals, + users: people + .filter((person) => ids.includes(person.id)) + .map((person) => { + const registration = registrations.find( + (entry) => (entry.linkedUserId ?? entry.id) === person.id, + ); + return { + ...(registration + ? { + registrationId: registration.id, + provisional: !registration.linkedUserId, + previousSub: registration.linkedUserId ? registration.id : undefined, + firstName: registration.firstName, + lastName: registration.lastName, + phone: registration.phone, + declaredTelegramId: registration.telegramId, + declaredTelegramUsername: registration.telegramUsername, + } + : {}), + sub: person.id, + name: person.name, + email: + registration && !registration.linkedUserId + ? registration.email + : (emails.get(person.id) ?? null), + azureId: azureByUser.get(person.id) ?? null, + telegramId: telegramByUser.get(person.id)?.telegramId ?? null, + telegramUsername: telegramByUser.get(person.id)?.telegramUsername ?? null, + roles: held.get(person.id) ?? [], + assigned: (assigned.get(person.id) ?? []).filter((key) => boundKeys.has(key)).sort(), + since: since.get(person.id) ?? {}, + }; + }), }; }, { isolationLevel: "repeatable read", accessMode: "read only" }, @@ -142,7 +228,7 @@ type Reader = Pick; type Transaction = Parameters[0]>[0]; /** Same command, same canonical form, whatever the key order jsonb stored it in. */ -function canonical(value: unknown): string { +export function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object") return `{${Object.entries(value) @@ -200,79 +286,96 @@ export async function changePeopleRoles(input: PeopleCommand): Promise { const replayed = await replay(tx); if (replayed) return replayed; - const catalog = await readCatalog(tx); - const [actor] = await tx.select({ id: user.id }).from(user).where(eq(user.id, input.actor)); - if (!actor) throw new RbacError(403, "The acting person has no account here."); - const [person] = await tx.select({ id: user.id }).from(user).where(eq(user.id, input.sub)); - if (!person) throw new RbacError(404, "That person was not found."); + return applyPeopleRoleCommand(tx, input); + }); +} - let plan; - try { - plan = planPeopleCommand(catalog, await assignedRoleKeys(input.sub, catalog, tx), input); - } catch (error) { - if (error instanceof PeopleCommandConflict) throw new RbacError(409, error.message); - throw error; - } - const subject = await readIdentitySubject(input.actor, tx); - const access = resolveAccess(catalog, [ - ...(await assignedRoleKeys(input.actor, catalog, tx)), - ...subject.roleKeys, - ]); - // Every assignment is checked against the actor's access before the first write. - const steps = plan.changes.map((change) => ({ - ...change, - target: catalog.roles.find((entry) => entry.key === change.role)!, - })); - for (const step of steps) { - const problem = peopleRoleChangeProblem({ - catalog, - actorId: input.actor, - actor: access, - targetId: input.sub, - role: step.target, - action: step.action, - }); - if (problem) { - logAuthorizationDenial(input.actor, "people-roles", [`${step.action}:${step.target.key}`]); - throw new RbacError(403, problem); - } - } +/** Caller holds the authorization lock and has refreshed the acting account membership. */ +export async function applyPeopleRoleCommand( + tx: Transaction, + input: PeopleCommand, +): Promise { + const catalog = await readCatalog(tx); + const [actor] = await tx.select({ id: user.id }).from(user).where(eq(user.id, input.actor)); + if (!actor) throw new RbacError(403, "The acting person has no account here."); + const [registration] = await tx + .select({ linkedUserId: peopleRegistration.linkedUserId }) + .from(peopleRegistration) + .where(eq(peopleRegistration.id, input.sub)); + if (registration?.linkedUserId) + throw new RbacError( + 409, + "This preliminary person now belongs to a login account; refresh People before editing roles.", + ); + const [person] = await tx.select({ id: user.id }).from(user).where(eq(user.id, input.sub)); + if (!person) throw new RbacError(404, "That person was not found."); - for (const step of steps) { - const operation = step.action === "assign" ? "role.assign" : "role.unassign"; - const before = await auditSnapshot(tx, catalog, operation, step.target.id); - if (step.action === "assign") - await tx - .insert(userRole) - .values({ roleId: step.target.id, userId: input.sub, assignedBy: input.actor }); - else - await tx - .delete(userRole) - .where(and(eq(userRole.roleId, step.target.id), eq(userRole.userId, input.sub))); - const after = await auditSnapshot(tx, catalog, operation, step.target.id); - await tx.insert(rbacAuditEvent).values({ - id: randomUUID(), - actorId: input.actor, - operation, - targetId: step.target.id, - before, - after: { - ...after, - via: "people", - userId: input.sub, - reason: input.reason, - commandId: input.id, - }, - }); + let plan; + try { + plan = planPeopleCommand(catalog, await assignedRoleKeys(input.sub, catalog, tx), input); + } catch (error) { + if (error instanceof PeopleCommandConflict) throw new RbacError(409, error.message); + throw error; + } + const subject = await readIdentitySubject(input.actor, tx); + const access = resolveAccess(catalog, [ + ...(await assignedRoleKeys(input.actor, catalog, tx)), + ...subject.roleKeys, + ]); + // Every assignment is checked against the actor's access before the first write. + const steps = plan.changes.map((change) => ({ + ...change, + target: catalog.roles.find((entry) => entry.key === change.role)!, + })); + for (const step of steps) { + const problem = peopleRoleChangeProblem({ + catalog, + actorId: input.actor, + actor: access, + targetId: input.sub, + role: step.target, + action: step.action, + }); + if (problem) { + logAuthorizationDenial(input.actor, "people-roles", [`${step.action}:${step.target.key}`]); + throw new RbacError(403, problem); } + } + + for (const step of steps) { + const operation = step.action === "assign" ? "role.assign" : "role.unassign"; + const before = await auditSnapshot(tx, catalog, operation, step.target.id); + if (step.action === "assign") + await tx + .insert(userRole) + .values({ roleId: step.target.id, userId: input.sub, assignedBy: input.actor }); + else + await tx + .delete(userRole) + .where(and(eq(userRole.roleId, step.target.id), eq(userRole.userId, input.sub))); + const after = await auditSnapshot(tx, catalog, operation, step.target.id); await tx.insert(rbacAuditEvent).values({ - id: receiptId, + id: randomUUID(), actorId: input.actor, - operation: "people.roles", - targetId: input.sub, - before: input, - after: plan.result, + operation, + targetId: step.target.id, + before, + after: { + ...after, + via: "people", + userId: input.sub, + reason: input.reason, + commandId: input.id, + }, }); - return plan.result; + } + await tx.insert(rbacAuditEvent).values({ + id: `people-command:${input.id}`, + actorId: input.actor, + operation: "people.roles", + targetId: input.sub, + before: input, + after: plan.result, }); + return plan.result; } diff --git a/src/db/people.ts b/src/db/people.ts new file mode 100644 index 0000000..1e60846 --- /dev/null +++ b/src/db/people.ts @@ -0,0 +1,22 @@ +import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { user } from "./auth-schema"; + +/** Declared contacts are not provider evidence and must never establish a login identity. */ +export const peopleRegistration = pgTable( + "people_registration", + { + id: text() + .primaryKey() + .references(() => user.id, { onDelete: "cascade" }), + firstName: text("first_name").notNull(), + lastName: text("last_name").notNull(), + email: text(), + phone: text(), + telegramId: text("telegram_id"), + telegramUsername: text("telegram_username"), + linkedUserId: text("linked_user_id").references(() => user.id), + createdBy: text("created_by").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [uniqueIndex("people_registration_linked_user_idx").on(table.linkedUserId)], +); diff --git a/src/db/schema.ts b/src/db/schema.ts index 79fecb4..12c76df 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -2,3 +2,4 @@ export * from "./auth-schema"; export * from "./evidence"; export * from "./rbac"; export * from "./access-snapshot"; +export * from "./people"; diff --git a/src/routes/api/internal/esign-users.ts b/src/routes/api/internal/esign-users.ts index c3e1799..90a411f 100644 --- a/src/routes/api/internal/esign-users.ts +++ b/src/routes/api/internal/esign-users.ts @@ -1,10 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; -import { asc } from "drizzle-orm"; +import { asc, eq, isNull } from "drizzle-orm"; import { snapshotClientId } from "@/auth/access-snapshot-token"; import { contactEmails } from "@/auth/contact-email"; import { projectionForClient } from "@/auth/access-projections"; import { db } from "@/db"; -import { user } from "@/db/schema"; +import { user, peopleRegistration } from "@/db/schema"; import { env } from "@/env"; const headers = { "Cache-Control": "private, no-store", Vary: "Authorization" }; @@ -26,6 +26,8 @@ export const Route = createFileRoute("/api/internal/esign-users")({ const people = await db .select({ id: user.id, name: user.name, email: user.email }) .from(user) + .leftJoin(peopleRegistration, eq(peopleRegistration.id, user.id)) + .where(isNull(peopleRegistration.id)) .orderBy(asc(user.name), asc(user.id)); const emails = await contactEmails(db, people, "everyone"); return Response.json( diff --git a/src/routes/api/internal/people-roles.ts b/src/routes/api/internal/people-roles.ts index 345164a..f14316f 100644 --- a/src/routes/api/internal/people-roles.ts +++ b/src/routes/api/internal/people-roles.ts @@ -1,3 +1,14 @@ +import { + createPeoplePersonSchema, + updatePeoplePersonSchema, + linkPeoplePersonSchema, +} from "@/auth/people-registration-command"; +import { + createPeoplePerson, + linkPeoplePerson, + updatePeoplePerson, +} from "@/auth/people-registration"; +import { z } from "zod"; import { createFileRoute } from "@tanstack/react-router"; import { peopleCommandSchema } from "@/auth/people-role-command"; import { serviceClientId } from "@/auth/access-snapshot-token"; @@ -35,11 +46,26 @@ export const Route = createFileRoute("/api/internal/people-roles")({ POST: async ({ request }) => { const refused = await backendClient(request, "idp:people:roles"); if (refused) return refused; - const parsed = peopleCommandSchema.safeParse(await request.json().catch(() => null)); + const parsed = z + .union([ + createPeoplePersonSchema, + updatePeoplePersonSchema, + linkPeoplePersonSchema, + peopleCommandSchema, + ]) + .safeParse(await request.json().catch(() => null)); if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400, headers }); try { - const result = await changePeopleRoles(parsed.data); + const command = parsed.data; + const result = + "operation" in command + ? command.operation === "create-person" + ? await createPeoplePerson(command) + : command.operation === "update-person" + ? await updatePeoplePerson(command) + : await linkPeoplePerson(command) + : await changePeopleRoles(command); return Response.json(result, { headers }); } catch (error) { if (error instanceof RbacError)