diff --git a/.env.example b/.env.example index 4b84c44..76fe084 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,3 @@ AZURE_EMAIL_SENDER="noreply@polinetwork.org" # OAUTH_BACKEND_CLIENT_ID="" # OAUTH_BACKEND_PRIVATE_JWK="" # the backend client's private JWK as JSON, with kid and alg # OAUTH_BOT_CLIENT_ID="" # same registered client ID as on the Telegram bot; required for its token socket -# LEGACY_ANONYMOUS="deny" # default; "allow" is the RFC rollback switch: anonymous requests may run procedures that record no actor diff --git a/README.md b/README.md index b0a9cf9..cba8215 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ Requirements: Every caller authenticates with a PoliNetwork IdP token ([RFC v3](https://github.com/PoliNetworkOrg/auth/blob/main/docs/idp-telegram-integration-rfc-v3.md), Phase 5): - **With a token** (`Authorization: Bearer `): the token is verified with [`@polinetwork/auth-kit`](https://github.com/PoliNetworkOrg/auth-kit), and the call is fully enforced. Every procedure declares, per actor kind, the scope it needs and, for people, the permission they must hold in the IdP's access snapshot. A token that fails verification is rejected. The bot acts for a Telegram user with `X-PN-Actor: telegram:`, which only a service token with `backend:tg:act-as` may send. -- **Without a token**: refused (`UNAUTHORIZED`). `LEGACY_ANONYMOUS=allow` is only a rollback switch: it lets anonymous requests run procedures that do not record who acted, with a null actor and no permissions, and logs each call as `[AUTH] legacy anonymous call`. Writes that record their actor (grants, `tg.groups.leaveChat`, label and web content writes) and the IdP procedures are token-only and refuse anonymous calls either way. +- **Without a token**: refused (`UNAUTHORIZED`). There is no anonymous access, and handlers always receive the token's actor in `ctx.actor`. -Procedures are built with `policy(...)` from `src/trpc.ts`; the bare tRPC procedure is not exported, and `tests/idp-router.test.ts` fails if any procedure lacks a policy. `policy(rules, { tokenOnly: true })` refuses anonymous calls and gives the handler a non-null `ctx.actor`. +Procedures are built with `policy(...)` from `src/trpc.ts`; the bare tRPC procedure is not exported, and `tests/idp-router.test.ts` fails if any procedure lacks a policy. Who acted always comes from the token, never from the input: procedures take no actor fields (`createdBy`, `modifiedBy`, `updatedBy`, `adderId`, `interruptedById`, `performerId`, `adminId`). Inputs are plain zod objects, so a key an older client still sends is stripped, not rejected. Content tables record the author in `*_by_sub` columns; rows written before the IdP migration keep their author's Telegram ID in `legacy_*_by_tg_id`. Grants are in `tg_grants` and record the grantor's IdP subject. People sign in only through the IdP: the backend has no login of its own. The bot records moderation actions with `tg.auditLog.record`, which takes an idempotency key and flags `idp_permission` actions the snapshot does not back. diff --git a/drizzle/0029_remove_audit_admin_id.sql b/drizzle/0029_remove_audit_admin_id.sql new file mode 100644 index 0000000..b2aec91 --- /dev/null +++ b/drizzle/0029_remove_audit_admin_id.sql @@ -0,0 +1,4 @@ +-- admin_id is superseded by actor_tg_id. Copy any value an older release wrote only to admin_id, so no author is lost. +UPDATE "tg_audit_log" SET "actor_tg_id" = "admin_id" WHERE "actor_tg_id" IS NULL AND "admin_id" IS NOT NULL;--> statement-breakpoint +DROP INDEX "auditlog_adminid_idx";--> statement-breakpoint +ALTER TABLE "tg_audit_log" DROP COLUMN "admin_id"; diff --git a/drizzle/meta/0029_snapshot.json b/drizzle/meta/0029_snapshot.json new file mode 100644 index 0000000..1b73d9c --- /dev/null +++ b/drizzle/meta/0029_snapshot.json @@ -0,0 +1,2923 @@ +{ + "id": "78ed20e8-993e-4903-b11d-50c1be0e7cdb", + "prevId": "082f20b3-1a97-4dc1-93aa-32e7aa47089f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.common_group_labels": { + "name": "common_group_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "common_group_labels_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#ffffff'" + }, + "legacy_created_by_tg_id": { + "name": "legacy_created_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "legacy_modified_by_tg_id": { + "name": "legacy_modified_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "common_group_labels_label_unique": { + "name": "common_group_labels_label_unique", + "nullsNotDistinct": false, + "columns": [ + "label" + ] + } + }, + "policies": {}, + "checkConstraints": { + "common_group_labels_creator_check": { + "name": "common_group_labels_creator_check", + "value": "\"common_group_labels\".\"legacy_created_by_tg_id\" IS NOT NULL OR \"common_group_labels\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.common_idp_state": { + "name": "common_idp_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_documents": { + "name": "esign_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pages": { + "name": "pages", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "esign_documents_request_version_idx": { + "name": "esign_documents_request_version_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"esign_documents\".\"request_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esign_documents_request_id_esign_requests_id_fk": { + "name": "esign_documents_request_id_esign_requests_id_fk", + "tableFrom": "esign_documents", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_drive_access": { + "name": "esign_drive_access", + "schema": "", + "columns": { + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "request_roles": { + "name": "request_roles", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "request_permissions": { + "name": "request_permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "sign_roles": { + "name": "sign_roles", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "sign_permissions": { + "name": "sign_permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "updated_by_sub": { + "name": "updated_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_events": { + "name": "esign_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_sub": { + "name": "actor_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "esign_events_request_idx": { + "name": "esign_events_request_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esign_events_request_id_esign_requests_id_fk": { + "name": "esign_events_request_id_esign_requests_id_fk", + "tableFrom": "esign_events", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_jobs": { + "name": "esign_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ticket_hash": { + "name": "ticket_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signer_id": { + "name": "signer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "places": { + "name": "places", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "step": { + "name": "step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "prepared": { + "name": "prepared", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_pdf": { + "name": "prepared_pdf", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "esign_jobs_request_idx": { + "name": "esign_jobs_request_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esign_jobs_request_id_esign_requests_id_fk": { + "name": "esign_jobs_request_id_esign_requests_id_fk", + "tableFrom": "esign_jobs", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "esign_jobs_signer_id_esign_signers_id_fk": { + "name": "esign_jobs_signer_id_esign_signers_id_fk", + "tableFrom": "esign_jobs", + "tableTo": "esign_signers", + "columnsFrom": [ + "signer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esign_jobs_ticket_hash_unique": { + "name": "esign_jobs_ticket_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "ticket_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_profiles": { + "name": "esign_profiles", + "schema": "", + "columns": { + "sub": { + "name": "sub", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fiscal_code": { + "name": "fiscal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "certificate_subject": { + "name": "certificate_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_requests": { + "name": "esign_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'in_progress'" + }, + "ordered": { + "name": "ordered", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "delivery": { + "name": "delivery", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "drives": { + "name": "drives", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "esign_requests_status_idx": { + "name": "esign_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esign_requests_created_by_idx": { + "name": "esign_requests_created_by_idx", + "columns": [ + { + "expression": "created_by_sub", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_signers": { + "name": "esign_signers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "places": { + "name": "places", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "signatures_done": { + "name": "signatures_done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "signed_at": { + "name": "signed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fiscal_code": { + "name": "fiscal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "certificate_subject": { + "name": "certificate_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "certificate_issuer": { + "name": "certificate_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decline_reason": { + "name": "decline_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "esign_signers_request_idx": { + "name": "esign_signers_request_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esign_signers_sub_idx": { + "name": "esign_signers_sub_idx", + "columns": [ + { + "expression": "sub", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esign_signers_request_position_idx": { + "name": "esign_signers_request_position_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esign_signers_request_id_esign_requests_id_fk": { + "name": "esign_signers_request_id_esign_requests_id_fk", + "tableFrom": "esign_signers", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people_files": { + "name": "people_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people_identities": { + "name": "people_identities", + "schema": "", + "columns": { + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_sub": { + "name": "account_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_number": { + "name": "member_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_id": { + "name": "telegram_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "people_identities_account_sub_unique": { + "name": "people_identities_account_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "account_sub" + ] + }, + "people_identities_member_number_unique": { + "name": "people_identities_member_number_unique", + "nullsNotDistinct": false, + "columns": [ + "member_number" + ] + }, + "people_identities_telegram_id_unique": { + "name": "people_identities_telegram_id_unique", + "nullsNotDistinct": false, + "columns": [ + "telegram_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people_receipt_requests": { + "name": "people_receipt_requests", + "schema": "", + "columns": { + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "officer_sub": { + "name": "officer_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_hash": { + "name": "original_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "people_receipt_request_identity_idx": { + "name": "people_receipt_request_identity_idx", + "columns": [ + { + "expression": "receipt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "people_receipt_requests_request_id_esign_requests_id_fk": { + "name": "people_receipt_requests_request_id_esign_requests_id_fk", + "tableFrom": "people_receipt_requests", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "people_receipt_requests_receipt_id_people_receipts_id_fk": { + "name": "people_receipt_requests_receipt_id_people_receipts_id_fk", + "tableFrom": "people_receipt_requests", + "tableTo": "people_receipts", + "columnsFrom": [ + "receipt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people_receipts": { + "name": "people_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "people_receipts_request_id_esign_requests_id_fk": { + "name": "people_receipts_request_id_esign_requests_id_fk", + "tableFrom": "people_receipts", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "people_receipts_number_unique": { + "name": "people_receipts_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + }, + "people_receipts_request_id_unique": { + "name": "people_receipts_request_id_unique", + "nullsNotDistinct": false, + "columns": [ + "request_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people_records": { + "name": "people_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "people_records_kind_idx": { + "name": "people_records_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_audit_log": { + "name": "tg_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tg_audit_log_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "target_id": { + "name": "target_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "until": { + "name": "until", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "actor_sub": { + "name": "actor_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_tg_id": { + "name": "actor_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "client": { + "name": "client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basis": { + "name": "basis", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'legacy'" + }, + "review": { + "name": "review", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auditlog_actor_tg_id_idx": { + "name": "auditlog_actor_tg_id_idx", + "columns": [ + { + "expression": "actor_tg_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auditlog_idempotency_key_idx": { + "name": "auditlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_grants": { + "name": "tg_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tg_grants_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "valid_since": { + "name": "valid_since", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_by_sub": { + "name": "granted_by_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_via_client": { + "name": "granted_via_client", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interrupted_at": { + "name": "interrupted_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "interrupted_by_sub": { + "name": "interrupted_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interrupted_by_tg_id": { + "name": "interrupted_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tg_grants_telegram_user_id_idx": { + "name": "tg_grants_telegram_user_id_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tg_grants_validity_check": { + "name": "tg_grants_validity_check", + "value": "\"tg_grants\".\"valid_since\" < \"tg_grants\".\"valid_until\"" + } + }, + "isRLSEnabled": false + }, + "public.tg_groups": { + "name": "tg_groups", + "schema": "", + "columns": { + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "hide": { + "name": "hide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tg_groups_link_unique": { + "name": "tg_groups_link_unique", + "nullsNotDistinct": false, + "columns": [ + "link" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_group_label_relations": { + "name": "tg_group_label_relations", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tg_group_label_relations_group_id_tg_groups_telegram_id_fk": { + "name": "tg_group_label_relations_group_id_tg_groups_telegram_id_fk", + "tableFrom": "tg_group_label_relations", + "tableTo": "tg_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "telegram_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tg_group_label_relations_label_id_common_group_labels_id_fk": { + "name": "tg_group_label_relations_label_id_common_group_labels_id_fk", + "tableFrom": "tg_group_label_relations", + "tableTo": "common_group_labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tg_group_label_relations_group_id_label_id_pk": { + "name": "tg_group_label_relations_group_id_label_id_pk", + "columns": [ + "group_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_messages": { + "name": "tg_messages", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "varchar(8704)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tg_messages_chat_author_timestamp_idx": { + "name": "tg_messages_chat_author_timestamp_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tg_messages_chat_id_message_id_pk": { + "name": "tg_messages_chat_id_message_id_pk", + "columns": [ + "chat_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_test": { + "name": "tg_test", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tg_test_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_users": { + "name": "tg_users", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lang_code": { + "name": "lang_code", + "type": "varchar(35)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wa_group_label_relations": { + "name": "wa_group_label_relations", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wa_group_label_relations_group_id_wa_groups_group_id_fk": { + "name": "wa_group_label_relations_group_id_wa_groups_group_id_fk", + "tableFrom": "wa_group_label_relations", + "tableTo": "wa_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "group_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wa_group_label_relations_label_id_common_group_labels_id_fk": { + "name": "wa_group_label_relations_label_id_common_group_labels_id_fk", + "tableFrom": "wa_group_label_relations", + "tableTo": "common_group_labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wa_group_label_relations_group_id_label_id_pk": { + "name": "wa_group_label_relations_group_id_label_id_pk", + "columns": [ + "group_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wa_groups": { + "name": "wa_groups", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "wa_groups_group_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "hide": { + "name": "hide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wa_groups_link_unique": { + "name": "wa_groups_link_unique", + "nullsNotDistinct": false, + "columns": [ + "link" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web_associations": { + "name": "web_associations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_associations_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_it": { + "name": "description_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "facebook": { + "name": "facebook", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instagram": { + "name": "instagram", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tiktok": { + "name": "tiktok", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x": { + "name": "x", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "youtube": { + "name": "youtube", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram": { + "name": "telegram", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin": { + "name": "linkedin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spotify": { + "name": "spotify", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legacy_created_by_tg_id": { + "name": "legacy_created_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "legacy_modified_by_tg_id": { + "name": "legacy_modified_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_associations_creator_check": { + "name": "web_associations_creator_check", + "value": "\"web_associations\".\"legacy_created_by_tg_id\" IS NOT NULL OR \"web_associations\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_faq_categories": { + "name": "web_faq_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_faq_categories_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title_it": { + "name": "title_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legacy_created_by_tg_id": { + "name": "legacy_created_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "legacy_modified_by_tg_id": { + "name": "legacy_modified_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_faq_categories_creator_check": { + "name": "web_faq_categories_creator_check", + "value": "\"web_faq_categories\".\"legacy_created_by_tg_id\" IS NOT NULL OR \"web_faq_categories\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_faqs": { + "name": "web_faqs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_faqs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title_it": { + "name": "title_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_it": { + "name": "description_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "legacy_created_by_tg_id": { + "name": "legacy_created_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "legacy_modified_by_tg_id": { + "name": "legacy_modified_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "web_faqs_category_id_web_faq_categories_id_fk": { + "name": "web_faqs_category_id_web_faq_categories_id_fk", + "tableFrom": "web_faqs", + "tableTo": "web_faq_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_faqs_creator_check": { + "name": "web_faqs_creator_check", + "value": "\"web_faqs\".\"legacy_created_by_tg_id\" IS NOT NULL OR \"web_faqs\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_freshman_cards": { + "name": "web_freshman_cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "legacy_created_by_tg_id": { + "name": "legacy_created_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "legacy_modified_by_tg_id": { + "name": "legacy_modified_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_freshman_cards_creator_check": { + "name": "web_freshman_cards_creator_check", + "value": "\"web_freshman_cards\".\"legacy_created_by_tg_id\" IS NOT NULL OR \"web_freshman_cards\".\"created_by_sub\" IS NOT NULL" + }, + "web_freshman_cards_slot_check": { + "name": "web_freshman_cards_slot_check", + "value": "\"web_freshman_cards\".\"id\" in ('featured-1', 'featured-2', 'guides-1', 'guides-2', 'guides-3', 'guides-4', 'guides-5', 'materials-1', 'tools-1', 'tools-2', 'telegram-1', 'telegram-2', 'telegram-3', 'telegram-4', 'telegram-5', 'telegram-6', 'telegram-7', 'telegram-8')" + }, + "web_freshman_cards_icon_check": { + "name": "web_freshman_cards_icon_check", + "value": "\"web_freshman_cards\".\"icon\" in ('chart', 'monitor', 'book', 'map', 'news', 'money', 'folder', 'repeat', 'refresh', 'bell', 'user', 'users', 'cart', 'home')" + } + }, + "isRLSEnabled": false + }, + "public.web_freshman_faqs": { + "name": "web_freshman_faqs", + "schema": "", + "columns": { + "position": { + "name": "position", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "faq_id": { + "name": "faq_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "legacy_created_by_tg_id": { + "name": "legacy_created_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "legacy_modified_by_tg_id": { + "name": "legacy_modified_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "web_freshman_faqs_faq_id_web_faqs_id_fk": { + "name": "web_freshman_faqs_faq_id_web_faqs_id_fk", + "tableFrom": "web_freshman_faqs", + "tableTo": "web_faqs", + "columnsFrom": [ + "faq_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "web_freshman_faqs_faq_id_unique": { + "name": "web_freshman_faqs_faq_id_unique", + "nullsNotDistinct": false, + "columns": [ + "faq_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "web_freshman_faqs_creator_check": { + "name": "web_freshman_faqs_creator_check", + "value": "\"web_freshman_faqs\".\"legacy_created_by_tg_id\" IS NOT NULL OR \"web_freshman_faqs\".\"created_by_sub\" IS NOT NULL" + }, + "web_freshman_faqs_position_check": { + "name": "web_freshman_faqs_position_check", + "value": "\"web_freshman_faqs\".\"position\" between 0 and 4" + } + }, + "isRLSEnabled": false + }, + "public.web_group_link_reports": { + "name": "web_group_link_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_group_link_reports_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "report_type": { + "name": "report_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "reported_link": { + "name": "reported_link", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web_guides_matricole": { + "name": "web_guides_matricole", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_guides_matricole_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "legacy_created_by_tg_id": { + "name": "legacy_created_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "legacy_modified_by_tg_id": { + "name": "legacy_modified_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "web_guides_matricole_version_unique": { + "name": "web_guides_matricole_version_unique", + "nullsNotDistinct": false, + "columns": [ + "version" + ] + } + }, + "policies": {}, + "checkConstraints": { + "web_guides_matricole_creator_check": { + "name": "web_guides_matricole_creator_check", + "value": "\"web_guides_matricole\".\"legacy_created_by_tg_id\" IS NOT NULL OR \"web_guides_matricole\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_projects": { + "name": "web_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_projects_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_it": { + "name": "description_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "legacy_created_by_tg_id": { + "name": "legacy_created_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "legacy_modified_by_tg_id": { + "name": "legacy_modified_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_projects_creator_check": { + "name": "web_projects_creator_check", + "value": "\"web_projects\".\"legacy_created_by_tg_id\" IS NOT NULL OR \"web_projects\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_test": { + "name": "web_test", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_test_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.groups_view": { + "columns": { + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "hide": { + "name": "hide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "definition": "(select \"telegram_id\", \"title\", \"link\", \"hide\", 'tg' as \"type\" from \"tg_groups\") union all (select \"group_id\", \"title\", \"link\", \"hide\", 'wa' as \"type\" from \"wa_groups\")", + "name": "groups_view", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.group_label_relations_view": { + "columns": { + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "definition": "(select \"group_id\", \"label_id\", 'tg' as \"type\" from \"tg_group_label_relations\") union all (select \"group_id\", \"label_id\", 'wa' as \"type\" from \"wa_group_label_relations\")", + "name": "group_label_relations_view", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 01cc5fb..1a84cc5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1792682679485, "tag": "0028_idp_phase6_cleanup", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1793043027659, + "tag": "0029_remove_audit_admin_id", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/db/schema/tg/audit-log.ts b/src/db/schema/tg/audit-log.ts index 1dd900e..eec3f50 100644 --- a/src/db/schema/tg/audit-log.ts +++ b/src/db/schema/tg/audit-log.ts @@ -47,8 +47,6 @@ export const auditLog = createTable.tg( "audit_log", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - /** Superseded by `actorTgId`; still written for readers of the old shape. */ - adminId: bigint("admin_id", { mode: "number" }), targetId: bigint("target_id", { mode: "number" }).notNull(), groupId: bigint("group_id", { mode: "number" }), type: varchar("type", { length: 32 }).$type().notNull(), @@ -66,7 +64,6 @@ export const auditLog = createTable.tg( ...timeColumns, }, (t) => [ - index("auditlog_adminid_idx").on(t.adminId), index("auditlog_actor_tg_id_idx").on(t.actorTgId), uniqueIndex("auditlog_idempotency_key_idx").on(t.idempotencyKey), ] diff --git a/src/env.ts b/src/env.ts index c1c9100..7eea5a0 100644 --- a/src/env.ts +++ b/src/env.ts @@ -116,11 +116,6 @@ export const env = createEnv({ /** The backend client's private signing key as a JWK (JSON), with `kid` and `alg`. */ OAUTH_BACKEND_PRIVATE_JWK: z.string().min(1).optional(), IDP_SNAPSHOT_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(30_000), - /** - * RFC v3 §13 rollback switch. "deny" (Phase 5) refuses requests and sockets without a token; - * "allow" lets anonymous requests run procedures that do not record an actor, with no permissions. - */ - LEGACY_ANONYMOUS: z.enum(["allow", "deny"]).default("deny"), // env config NODE_ENV: NODE_ENV.default(nodeEnv), diff --git a/src/esign/index.ts b/src/esign/index.ts index 7afd8aa..eb580bc 100644 --- a/src/esign/index.ts +++ b/src/esign/index.ts @@ -27,32 +27,16 @@ function resolveDrives() { return env.ESIGN_DRIVES.map((drive) => createGraphDrive({ client: getAzureClient, ...drive })) } -/** The dashboard's development user (routers/esign), who holds every e-signing permission without an IdP. */ -const DEV_SUB = "agent-preview-user" -const ESIGN_PERMISSIONS = ["esign:sign", "esign:requests:manage", "esign:drives:manage"] - -/** Roles and permissions shown in development without an IdP, so the drive settings can be tried. */ -const DEV_CATALOG: AccessCatalog = { - roles: [ - { key: "direttivo", name: "Direttivo" }, - { key: "socio", name: "Socio" }, - ], - permissions: ESIGN_PERMISSIONS.map((key) => ({ key, name: key })), -} - function accessOf(sub: string): Access | null { const index = idp?.snapshot?.current() - if (!index) - return env.NODE_ENV === "development" && sub === DEV_SUB - ? { roles: ["direttivo"], permissions: ESIGN_PERMISSIONS } - : null + if (!index) return null const subject = index.bySub(sub) return subject ? { roles: index.roles(subject), permissions: index.permissions(subject) } : null } function catalog(): AccessCatalog { const index = idp?.snapshot?.current() - if (!index) return env.NODE_ENV === "development" ? DEV_CATALOG : { roles: [], permissions: [] } + if (!index) return { roles: [], permissions: [] } const { roles, permissions } = index.catalog() return { roles: [...roles], permissions: [...permissions] } } diff --git a/src/idp/auth.ts b/src/idp/auth.ts index 37ec067..0a59741 100644 --- a/src/idp/auth.ts +++ b/src/idp/auth.ts @@ -10,13 +10,11 @@ import { export const ACT_AS_SCOPE = "backend:tg:act-as" /** - * How a request authenticated (RFC v3 §6.4, §13): - * - `anonymous`: no bearer token; refused unless `LEGACY_ANONYMOUS=allow` (rollback only); + * How a request authenticated (RFC v3 §6.4): * - `token`: a verified IdP access token and the actor it resolves to; - * - `rejected`: a token or actor header was sent but cannot be accepted. Never falls back to legacy. + * - `rejected`: no token, or a token or actor header that cannot be accepted. */ export type RequestAuth = - | { kind: "anonymous" } | { kind: "token"; actor: Actor; scopes: ReadonlySet } | { kind: "rejected"; status: 401 | 403 | 503; reason: string } @@ -36,7 +34,7 @@ export async function authenticate( const { authorization, actor: actorHeader } = headers if (!authorization) { if (actorHeader != null) return { kind: "rejected", status: 401, reason: "Actor header without a token" } - return { kind: "anonymous" } + return { kind: "rejected", status: 401, reason: "Missing bearer token" } } const token = BEARER.exec(authorization)?.[1] if (!token) return { kind: "rejected", status: 401, reason: "Malformed authorization header" } diff --git a/src/idp/index.ts b/src/idp/index.ts index d050318..48d1069 100644 --- a/src/idp/index.ts +++ b/src/idp/index.ts @@ -39,9 +39,7 @@ function parsePrivateKey(value: string): ServiceTokenSourceOptions["privateKey"] function configure() { if (!env.IDP_PUBLIC_URL || !env.OAUTH_BACKEND_RESOURCE_URI) { - logger.warn( - "[IDP] Token authentication is not configured: tokens are rejected, and only anonymous calls allowed by LEGACY_ANONYMOUS run" - ) + logger.warn("[IDP] Token authentication is not configured: every request is rejected") return null } const endpoints = idpEndpoints({ diff --git a/src/idp/policies.ts b/src/idp/policies.ts index 69f8ea1..ed2b384 100644 --- a/src/idp/policies.ts +++ b/src/idp/policies.ts @@ -25,7 +25,7 @@ export function botReadOrDashboard(permission: string): Policy { /** Public website data, also readable in the dashboard. */ export const publicData: Policy = { service: { scope: SCOPE.publicRead }, ...dashboard("admin:access") } -/** Hidden groups are dashboard data (RFC v3 §8); anonymous callers (only while `LEGACY_ANONYMOUS=allow`) keep the old behaviour. */ -export function seesHiddenGroups(actor: Actor | null): boolean { - return actor === null || actor.kind === "user" +/** Hidden groups are dashboard data (RFC v3 §8). */ +export function seesHiddenGroups(actor: Actor): boolean { + return actor.kind === "user" } diff --git a/src/people/router.ts b/src/people/router.ts index eb4bef2..adeeb14 100644 --- a/src/people/router.ts +++ b/src/people/router.ts @@ -11,8 +11,8 @@ import type { Viewer } from "./types" /** Factory keeps policy and shape tests independent of databases and external services. */ export function createPeopleRouter(people: PeopleService) { - const user = policy({ user: { scope: SCOPE.admin } }, { tokenOnly: true }).use(async ({ ctx, next }) => { - if (ctx.actor?.kind !== "user") throw new TRPCError({ code: "FORBIDDEN" }) + const user = policy({ user: { scope: SCOPE.admin } }).use(async ({ ctx, next }) => { + if (ctx.actor.kind !== "user") throw new TRPCError({ code: "FORBIDDEN" }) const viewer: Viewer = { sub: ctx.actor.sub, telegramId: ctx.actor.telegramId, can: ctx.can } const result = await next({ ctx: { viewer } }) if (!result.ok && result.error.cause instanceof EsignError) { diff --git a/src/routers/azure/members.ts b/src/routers/azure/members.ts index dd7a434..90243cf 100644 --- a/src/routers/azure/members.ts +++ b/src/routers/azure/members.ts @@ -18,8 +18,8 @@ const personName = z .max(64) .regex(/^[\p{L}\p{M}' -]+$/u, "Only letters, spaces, apostrophes and hyphens") -function auditActor(actor: Actor | null) { - return actor?.kind === "user" ? { sub: actor.sub, client: actor.client } : { legacy: true } +function auditActor(actor: Actor) { + return actor.kind === "user" ? { sub: actor.sub, client: actor.client } : { client: actor.client } } /** Entra's message when the sign-in name or an address already belongs to another object. */ @@ -39,7 +39,7 @@ export default createTRPCRouter({ * Change the member number (`employeeId`) of an existing user, refusing a number another user holds, and * optionally email the new number. Token callers only: it can send mail to a caller-supplied address. */ - setAssocNumber: policy(dashboard("azure:members:write"), { tokenOnly: true }) + setAssocNumber: policy(dashboard("azure:members:write")) .input( z.object({ userId: z.string().min(1), diff --git a/src/routers/esign.ts b/src/routers/esign.ts index 5c7ef07..3c3f47c 100644 --- a/src/routers/esign.ts +++ b/src/routers/esign.ts @@ -1,7 +1,6 @@ import type { Actor } from "@polinetwork/auth-kit" import { TRPCError } from "@trpc/server" import { z } from "zod" -import { env } from "@/env" import { esign } from "@/esign" import { MAX_PDF_BYTES } from "@/esign/drive/types" import { EsignError, type EsignErrorCode } from "@/esign/errors" @@ -17,9 +16,6 @@ const MANAGE = "esign:requests:manage" /** Who may use each drive. */ const MANAGE_DRIVES = "esign:drives:manage" -/** The dashboard's agent-mode user, on the anonymous path (`LEGACY_ANONYMOUS=allow`) in development only. */ -const DEV_SUB = "agent-preview-user" - const TRPC_CODE: Record = { not_found: "NOT_FOUND", forbidden: "FORBIDDEN", @@ -39,11 +35,9 @@ const TRPC_CODE: Record = { agent_outdated: "PRECONDITION_FAILED", } -function callerOf(ctx: { actor: Actor | null; can: (permission: string) => boolean }): Caller { - if (ctx.actor?.kind === "user") +function callerOf(ctx: { actor: Actor; can: (permission: string) => boolean }): Caller { + if (ctx.actor.kind === "user") return { sub: ctx.actor.sub, canManage: ctx.can(MANAGE), canManageDrives: ctx.can(MANAGE_DRIVES) } - if (ctx.actor === null && env.NODE_ENV === "development") - return { sub: DEV_SUB, canManage: true, canManageDrives: true } throw new TRPCError({ code: "FORBIDDEN", message: "Only dashboard users can sign" }) } diff --git a/src/routers/groups/labels.ts b/src/routers/groups/labels.ts index a6a4da7..c7337ce 100644 --- a/src/routers/groups/labels.ts +++ b/src/routers/groups/labels.ts @@ -31,7 +31,7 @@ export default createTRPCRouter({ return results }), - create: policy(dashboard("groups:labels:write"), { tokenOnly: true }) + create: policy(dashboard("groups:labels:write")) .input( z.object({ label, @@ -86,7 +86,7 @@ export default createTRPCRouter({ return result }), - modify: policy(dashboard("groups:labels:write"), { tokenOnly: true }) + modify: policy(dashboard("groups:labels:write")) .input( z.object({ label, diff --git a/src/routers/me.ts b/src/routers/me.ts index 4cd80c1..38657ab 100644 --- a/src/routers/me.ts +++ b/src/routers/me.ts @@ -7,8 +7,8 @@ export const meRouter = createTRPCRouter({ * The signed-in dashboard user's own permissions from the access snapshot (RFC v3 §8.2, §11.3). * Drives the dashboard UI; the backend still checks every call. Empty while the snapshot is stale. */ - access: policy({ user: { scope: SCOPE.admin } }, { tokenOnly: true }).query(({ ctx }) => { - if (ctx.actor?.kind !== "user") throw new TRPCError({ code: "FORBIDDEN" }) + access: policy({ user: { scope: SCOPE.admin } }).query(({ ctx }) => { + if (ctx.actor.kind !== "user") throw new TRPCError({ code: "FORBIDDEN" }) const subject = ctx.access?.subjectBySub(ctx.actor.sub) const index = ctx.access?.current() ?? null return { diff --git a/src/routers/tg/access.ts b/src/routers/tg/access.ts index e76be2e..811440b 100644 --- a/src/routers/tg/access.ts +++ b/src/routers/tg/access.ts @@ -21,7 +21,7 @@ export default createTRPCRouter({ * (RFC v3 §6.3, §10.2). The bot uses it for actor and target checks. Permissions are empty when * the snapshot is stale or the user has not linked Telegram. */ - resolve: policy({ service: { scope: SCOPE.tgRead }, telegram: { scope: SCOPE.tgRead } }, { tokenOnly: true }) + resolve: policy({ service: { scope: SCOPE.tgRead }, telegram: { scope: SCOPE.tgRead } }) .input(z.object({ telegramIds: z.array(telegramId).min(1).max(100) })) .query(async ({ input, ctx }) => { const ids = [...new Set(input.telegramIds)] diff --git a/src/routers/tg/audit-log.ts b/src/routers/tg/audit-log.ts index a5cd749..7251a08 100644 --- a/src/routers/tg/audit-log.ts +++ b/src/routers/tg/audit-log.ts @@ -1,5 +1,5 @@ import { TRPCError } from "@trpc/server" -import { desc, eq, sql } from "drizzle-orm" +import { desc, eq } from "drizzle-orm" import { alias } from "drizzle-orm/pg-core" import { z } from "zod" import { DB, SCHEMA } from "@/db" @@ -43,7 +43,7 @@ export default createTRPCRouter({ * action the snapshot does not back is stored and flagged for review instead. A repeated * `idempotencyKey` records nothing and returns the first record. */ - record: policy({ service: { scope: SCOPE.tgAudit } }, { tokenOnly: true }) + record: policy({ service: { scope: SCOPE.tgAudit } }) .input( z .object({ @@ -88,7 +88,6 @@ export default createTRPCRouter({ const [row] = await tx .insert(AUDIT_LOG) .values({ - adminId: input.actorTelegramId, actorTgId: input.actorTelegramId, actorSub: subject?.sub ?? null, targetId: input.targetId, @@ -134,8 +133,7 @@ export default createTRPCRouter({ .from(AUDIT_LOG) .where((t) => eq(t.targetId, input.targetId)) .leftJoin(SCHEMA.TG.groups, eq(AUDIT_LOG.groupId, SCHEMA.TG.groups.telegramId)) - // Rows written by an older release during a rollback have only admin_id. - .leftJoin(admin, eq(sql`coalesce(${AUDIT_LOG.actorTgId}, ${AUDIT_LOG.adminId})`, admin.userId)) + .leftJoin(admin, eq(AUDIT_LOG.actorTgId, admin.userId)) .orderBy(desc(AUDIT_LOG.createdAt)) return await Promise.all( diff --git a/src/routers/tg/grants.ts b/src/routers/tg/grants.ts index 0efd376..888c677 100644 --- a/src/routers/tg/grants.ts +++ b/src/routers/tg/grants.ts @@ -33,8 +33,6 @@ export default createTRPCRouter({ isGranted: z.boolean(), grant: z.nullable( z.object({ - /** @deprecated Always null: grants are made by IdP subjects. Use `grantedBySub`. */ - grantedBy: z.null(), grantedBySub: z.string(), validSince: z.date(), validUntil: z.date(), @@ -46,15 +44,15 @@ export default createTRPCRouter({ const grant = (await activeGrantByUser([input.userId])).get(input.userId) if (!grant) return { isGranted: false, grant: null } - const { grantedBy, grantedBySub, validSince, validUntil } = grant - return { isGranted: true, grant: { grantedBy, grantedBySub, validSince, validUntil } } + const { grantedBySub, validSince, validUntil } = grant + return { isGranted: true, grant: { grantedBySub, validSince, validUntil } } }), /** * Escalating write: dashboard users only, never the bot acting for someone (RFC v3 §8.1). The * grantor is the token's user. */ - create: policy(dashboard("tg:grants:manage"), { tokenOnly: true }) + create: policy(dashboard("tg:grants:manage")) .input( z.object({ userId: z.number(), @@ -99,7 +97,6 @@ export default createTRPCRouter({ const author = await describeActor(actor) await WSS.logGrantCreate({ userId: input.userId, - adminId: author.telegramId ? Number(author.telegramId) : null, actor: author, validSince: input.since, validUntil: input.until, @@ -118,13 +115,10 @@ export default createTRPCRouter({ * De-escalating write: the bot's 🛑 button acting for a Telegram user, or the dashboard. Who * interrupted is the token's actor. */ - interrupt: policy( - { - telegram: { scope: ACT_AS_SCOPE, permission: "tg:grants:manage" }, - ...dashboard("tg:grants:manage"), - }, - { tokenOnly: true } - ) + interrupt: policy({ + telegram: { scope: ACT_AS_SCOPE, permission: "tg:grants:manage" }, + ...dashboard("tg:grants:manage"), + }) .input( z.object({ userId: z.number(), @@ -171,7 +165,6 @@ export default createTRPCRouter({ const author = await describeActor(actor) await WSS.logGrantInterrupt({ userId: input.userId, - adminId: author.telegramId ? Number(author.telegramId) : null, actor: author, }) } diff --git a/src/routers/tg/groups.ts b/src/routers/tg/groups.ts index f54a8f7..3f2935c 100644 --- a/src/routers/tg/groups.ts +++ b/src/routers/tg/groups.ts @@ -213,7 +213,7 @@ export default createTRPCRouter({ }), /** The performer is the token's user. */ - leaveChat: policy(dashboard("tg:groups:manage"), { tokenOnly: true }) + leaveChat: policy(dashboard("tg:groups:manage")) .input( z.object({ chatId: z.number(), @@ -223,7 +223,6 @@ export default createTRPCRouter({ const performer = await describeActor(ctx.actor) const left = await WSS.leaveChat({ chatId: input.chatId, - performerId: performer.telegramId ? Number(performer.telegramId) : null, performer, }) if (!left) return { error: "BOT_ERROR" } diff --git a/src/routers/tg/users.ts b/src/routers/tg/users.ts index 219d777..a458289 100644 --- a/src/routers/tg/users.ts +++ b/src/routers/tg/users.ts @@ -48,9 +48,9 @@ export default createTRPCRouter({ /** * How many Telegram users were first stored on each day, from `created_at`, oldest day first. Days without new users * are omitted. Only dates and counts leave the database: the dashboard charts user growth without loading or - * decrypting the users themselves. New procedures take tokens only (no legacy anonymous path). + * decrypting the users themselves. */ - getCreationHistory: policy(dashboard("tg:users:read"), { tokenOnly: true }) + getCreationHistory: policy(dashboard("tg:users:read")) .output(CreationHistorySchema) .query(async () => { try { diff --git a/src/routers/web/associations.ts b/src/routers/web/associations.ts index 303d6c9..bdfd2c1 100644 --- a/src/routers/web/associations.ts +++ b/src/routers/web/associations.ts @@ -75,7 +75,7 @@ export default createTRPCRouter({ return associations.map(formatAssociation) }), - addAssociation: policy(dashboard("web:content:write"), { tokenOnly: true }) + addAssociation: policy(dashboard("web:content:write")) .input( z .instanceof(FormData) @@ -102,7 +102,7 @@ export default createTRPCRouter({ return formatAssociation(res) }), - editAssociation: policy(dashboard("web:content:write"), { tokenOnly: true }) + editAssociation: policy(dashboard("web:content:write")) .input( z .instanceof(FormData) @@ -135,7 +135,7 @@ export default createTRPCRouter({ return formatAssociation(res) }), - editAssociationLinks: policy(dashboard("web:content:write"), { tokenOnly: true }) + editAssociationLinks: policy(dashboard("web:content:write")) .input( z.object({ id: z.number(), diff --git a/src/routers/web/faqs.ts b/src/routers/web/faqs.ts index f6b99cd..5370889 100644 --- a/src/routers/web/faqs.ts +++ b/src/routers/web/faqs.ts @@ -54,7 +54,7 @@ export default createTRPCRouter({ return result }), - addFaqs: policy(dashboard("web:content:write"), { tokenOnly: true }) + addFaqs: policy(dashboard("web:content:write")) .input( z.object({ titleIt: z.string(), @@ -81,7 +81,7 @@ export default createTRPCRouter({ return res }), - addFaqsCategory: policy(dashboard("web:content:write"), { tokenOnly: true }) + addFaqsCategory: policy(dashboard("web:content:write")) .input( z.object({ titleIt: z.string(), @@ -104,7 +104,7 @@ export default createTRPCRouter({ return res }), - editFaqs: policy(dashboard("web:content:write"), { tokenOnly: true }) + editFaqs: policy(dashboard("web:content:write")) .input( z.object({ id: z.number(), @@ -134,7 +134,7 @@ export default createTRPCRouter({ return res }), - editFaqsCategory: policy(dashboard("web:content:write"), { tokenOnly: true }) + editFaqsCategory: policy(dashboard("web:content:write")) .input( z.object({ id: z.number(), diff --git a/src/routers/web/freshman.ts b/src/routers/web/freshman.ts index 4396fb5..d611e20 100644 --- a/src/routers/web/freshman.ts +++ b/src/routers/web/freshman.ts @@ -57,7 +57,7 @@ export default createTRPCRouter({ ) }), - updateCard: policy(dashboard("web:content:write"), { tokenOnly: true }) + updateCard: policy(dashboard("web:content:write")) .input(updateFreshmanCardInput) .mutation(async ({ input, ctx }) => { const { id, ...fields } = input @@ -69,7 +69,7 @@ export default createTRPCRouter({ return { error: null } }), - moveCard: policy(dashboard("web:content:write"), { tokenOnly: true }) + moveCard: policy(dashboard("web:content:write")) .input(moveFreshmanCardInput) .mutation(async ({ input, ctx }) => { const index = freshmanSlots.findIndex((slot) => slot.id === input.id) @@ -102,7 +102,7 @@ export default createTRPCRouter({ return { error: null } }), - selectFaq: policy(dashboard("web:content:write"), { tokenOnly: true }) + selectFaq: policy(dashboard("web:content:write")) .input(selectFreshmanFaqInput) .mutation(async ({ input, ctx }) => { const author = modifiedByColumns(authorOf(ctx.actor)) diff --git a/src/routers/web/guides_matricole.ts b/src/routers/web/guides_matricole.ts index a675b1d..49a7c8c 100644 --- a/src/routers/web/guides_matricole.ts +++ b/src/routers/web/guides_matricole.ts @@ -41,7 +41,7 @@ export default createTRPCRouter({ return res[0] || null }), - addGuide: policy(dashboard("web:content:write"), { tokenOnly: true }) + addGuide: policy(dashboard("web:content:write")) .input( z .instanceof(FormData) diff --git a/src/routers/web/projects.ts b/src/routers/web/projects.ts index 1ae4c46..15bbeed 100644 --- a/src/routers/web/projects.ts +++ b/src/routers/web/projects.ts @@ -42,7 +42,7 @@ export default createTRPCRouter({ return await DB.select().from(PROJECTS).orderBy(asc(PROJECTS.order)) }), - addProject: policy(dashboard("web:content:write"), { tokenOnly: true }) + addProject: policy(dashboard("web:content:write")) .input( z .instanceof(FormData) @@ -71,7 +71,7 @@ export default createTRPCRouter({ return res }), - editProject: policy(dashboard("web:content:write"), { tokenOnly: true }) + editProject: policy(dashboard("web:content:write")) .input( z .instanceof(FormData) diff --git a/src/server.ts b/src/server.ts index 440e75f..e8f5846 100644 --- a/src/server.ts +++ b/src/server.ts @@ -46,7 +46,6 @@ app.use( actor: c.req.header(ACTOR_HEADER), }), access: idp?.snapshot ?? null, - legacyAnonymous: env.LEGACY_ANONYMOUS, }), }) ) diff --git a/src/trpc.ts b/src/trpc.ts index ebd90d3..b03fcdd 100644 --- a/src/trpc.ts +++ b/src/trpc.ts @@ -7,12 +7,10 @@ import type { RequestAuth } from "./idp/auth" import { logger } from "./logger" export type Context = { - /** Set by the HTTP adapter. Direct callers (tests, scripts) are anonymous. */ + /** Set by the HTTP adapter. Direct callers (tests, scripts) without it are refused. */ auth?: RequestAuth /** Permission source for token callers; null denies every permission. */ access?: Pick | null - /** RFC v3 §13: whether requests without a token may take the anonymous path. Default "deny". */ - legacyAnonymous?: "allow" | "deny" } /** @@ -24,11 +22,7 @@ export type PolicyRule = { scope: string | readonly string[]; permission?: strin /** Actor kinds a procedure accepts. A kind that is not listed is denied. */ export type Policy = { service?: PolicyRule; telegram?: PolicyRule; user?: PolicyRule } -type Meta = { - policy?: Policy - /** Procedures with no anonymous behaviour, such as writes that record their actor, refuse anonymous callers. */ - tokenOnly?: boolean -} +type Meta = { policy?: Policy } const t = initTRPC .context() @@ -72,8 +66,8 @@ const timingMiddleware = t.middleware(async ({ next, path }) => { return result }) -/** What handlers see: the enforced actor, or null on the anonymous path. */ -type ActorContext = { actor: Actor | null; can: (permission: string) => boolean } +/** What handlers see: the token's enforced actor. */ +type ActorContext = { actor: Actor; can: (permission: string) => boolean } const REJECTION_CODES = { 401: "UNAUTHORIZED", 403: "FORBIDDEN", 503: "SERVICE_UNAVAILABLE" } as const @@ -87,10 +81,8 @@ function deny(path: string, actor: Actor, reason: string, permission?: string): /** * Deny-by-default enforcement (RFC v3 §8). A procedure without a policy is refused at runtime, - * and a test walks the router so it never ships. Token callers are fully enforced, and the actor - * comes only from the token. Anonymous callers reach procedures that are not token-only, with a - * null actor and no permissions, only while `LEGACY_ANONYMOUS=allow` (the RFC v3 §13 rollback - * switch); every such call is logged. + * and a test walks the router so it never ships. Every call needs an IdP access token, and the + * actor comes only from the token. */ const enforcePolicy = t.middleware(async ({ ctx, meta, path, next }) => { const policy = meta?.policy @@ -98,17 +90,10 @@ const enforcePolicy = t.middleware(async ({ ctx, meta, path, next }) => { logger.error({ procedure: path }, "[AUTH] procedure without a policy") throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Procedure without a policy" }) } - const auth = ctx.auth ?? { kind: "anonymous" } + const auth = ctx.auth ?? { kind: "rejected", status: 401, reason: "Missing bearer token" } if (auth.kind === "rejected") throw new TRPCError({ code: REJECTION_CODES[auth.status], message: auth.reason }) - if (auth.kind === "anonymous") { - if (meta.tokenOnly || (ctx.legacyAnonymous ?? "deny") !== "allow") throw new TRPCError({ code: "UNAUTHORIZED" }) - logger.info({ procedure: path }, "[AUTH] legacy anonymous call") - const legacy: ActorContext = { actor: null, can: () => false } - return next({ ctx: legacy }) - } - const { actor, scopes } = auth const rule = policy[actor.kind] if (!rule) deny(path, actor, `Not available to ${actor.kind} callers`) @@ -123,24 +108,5 @@ const enforcePolicy = t.middleware(async ({ ctx, meta, path, next }) => { const baseProcedure = t.procedure.use(timingMiddleware) -const enforced = (rules: Policy, tokenOnly: boolean) => - baseProcedure.meta({ policy: rules, tokenOnly }).use(enforcePolicy) - -/** Refuses anonymous callers, so handlers always have the token's actor. */ -const tokenOnlyProcedure = (rules: Policy) => - enforced(rules, true).use(({ ctx, next }) => { - // Unreachable: enforcePolicy refuses anonymous calls to token-only procedures. - if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" }) - return next({ ctx: { actor: ctx.actor } }) - }) - -/** - * A procedure with its RFC v3 §8 policy. Every procedure must be built from this. `tokenOnly` - * refuses anonymous callers even while `LEGACY_ANONYMOUS=allow`, and gives the handler a non-null - * actor: use it for every procedure that records who acted. - */ -export function policy(rules: Policy, options: { tokenOnly: true }): ReturnType -export function policy(rules: Policy, options?: { tokenOnly?: false }): ReturnType -export function policy(rules: Policy, options: { tokenOnly?: boolean } = {}) { - return options.tokenOnly ? tokenOnlyProcedure(rules) : enforced(rules, false) -} +/** A procedure with its RFC v3 §8 policy. Every procedure must be built from this. */ +export const policy = (rules: Policy) => baseProcedure.meta({ policy: rules }).use(enforcePolicy) diff --git a/src/utils/grants.ts b/src/utils/grants.ts index 4714705..7cdf51d 100644 --- a/src/utils/grants.ts +++ b/src/utils/grants.ts @@ -5,14 +5,8 @@ const GRANTS = SCHEMA.TG.grants /** A grant, made by an IdP subject (RFC v3 §9.3). */ export type Grant = { - /** @deprecated Always `idp:`; kept for dashboards built against SDK v2. Use `id`. */ - key: string id: number - /** @deprecated Always `"idp"`: the legacy grants table was dropped in Phase 6. */ - source: "idp" userId: number - /** @deprecated Always null: grants are made by IdP subjects. Use `grantedBySub`. */ - grantedBy: null grantedBySub: string validSince: Date validUntil: Date @@ -48,11 +42,8 @@ export async function listGrants( return rows.map( (row): Grant => ({ - key: `idp:${row.id}`, id: row.id, - source: "idp", userId: row.telegramUserId, - grantedBy: null, grantedBySub: row.grantedBySub, validSince: row.validSince, validUntil: row.validUntil, diff --git a/src/websocket/telegram.ts b/src/websocket/telegram.ts index 6b52b1d..1edd53e 100644 --- a/src/websocket/telegram.ts +++ b/src/websocket/telegram.ts @@ -17,8 +17,6 @@ export interface ToClient { logGrantCreate: ( data: { userId: number - /** Superseded by `actor`; null when the actor has no Telegram ID. */ - adminId: number | null actor: SocketActor validSince: Date validUntil: Date @@ -29,8 +27,6 @@ export interface ToClient { logGrantInterrupt: ( data: { userId: number - /** Superseded by `actor`; null when the actor has no Telegram ID. */ - adminId: number | null actor: SocketActor }, cb: (error: string | null) => void @@ -38,8 +34,6 @@ export interface ToClient { leaveChat: ( data: { chatId: number - /** Superseded by `performer`; null when the performer has no Telegram ID. */ - performerId: number | null performer: SocketActor }, cb: (ok: boolean) => void diff --git a/tests/azure-router.test.ts b/tests/azure-router.test.ts index 6a44a16..3445ea8 100644 --- a/tests/azure-router.test.ts +++ b/tests/azure-router.test.ts @@ -264,7 +264,7 @@ describe("Azure tRPC routes without credentials", () => { ).rejects.toMatchObject({ code: "BAD_REQUEST" }) }) - it("refuses anonymous member number changes", async () => { + it("refuses member number changes without a token", async () => { const caller = await createCaller() await expect( @@ -332,12 +332,8 @@ describe("Azure directory token authorization", () => { await expect(caller.groups.getAll()).rejects.toMatchObject({ code: "FORBIDDEN" }) }) - it("refuses anonymous reads unless LEGACY_ANONYMOUS allows them", async () => { - for (const context of [{}, { legacyAnonymous: "deny" } as const]) { - const caller = await createCaller(context) - await expect(caller.members.getAll()).rejects.toMatchObject({ code: "UNAUTHORIZED" }) - } - expect(await (await createCaller({ legacyAnonymous: "allow" })).members.getAll()).toHaveLength(3) + it("refuses calls without a token", async () => { + await expect((await createCaller({})).members.getAll()).rejects.toMatchObject({ code: "UNAUTHORIZED" }) }) }) diff --git a/tests/idp-auth.test.ts b/tests/idp-auth.test.ts index 4557011..a2b18b2 100644 --- a/tests/idp-auth.test.ts +++ b/tests/idp-auth.test.ts @@ -31,11 +31,15 @@ const authenticator = (result: VerifiedToken | AuthKitError) => ({ }) describe("authenticate", () => { - it("treats a request without a token as anonymous", async () => { - expect(await authenticate({}, authenticator(bot))).toEqual({ kind: "anonymous" }) + it("rejects a request without a token", async () => { + expect(await authenticate({}, authenticator(bot))).toEqual({ + kind: "rejected", + status: 401, + reason: "Missing bearer token", + }) }) - it("never lets an actor header or a token fall back to the legacy path", async () => { + it("rejects an actor header without a token", async () => { expect(await authenticate({ actor: "telegram:123" }, authenticator(bot))).toMatchObject({ kind: "rejected", status: 401, diff --git a/tests/idp-policy.test.ts b/tests/idp-policy.test.ts index 6622e7a..4319fd1 100644 --- a/tests/idp-policy.test.ts +++ b/tests/idp-policy.test.ts @@ -23,19 +23,15 @@ const router = createTRPCRouter({ }).query(({ ctx }) => ({ actor: ctx.actor })), dashboardOnly: policy({ user: { scope: "backend:admin", permission: "admin:access" } }).query(() => "ok"), multiScope: policy({ service: { scope: ["backend:tg:read", "backend:public:read"] } }).query(() => "ok"), - tokenOnly: policy({ service: { scope: "backend:tg:read" } }, { tokenOnly: true }).query(({ ctx }) => ({ - client: ctx.actor.client, - })), }) const bot: Actor = { kind: "service", client: "telegram-bot" } const moderator: Actor = { kind: "telegram", client: "telegram-bot", telegramId: "123", sub: "usr_mod" } const admin: Actor = { kind: "user", client: "admin-dashboard", sub: "usr_admin", telegramId: null } -function caller(auth: RequestAuth, permissions: string[] = [], legacyAnonymous?: "allow" | "deny") { +function caller(auth: RequestAuth, permissions: string[] = []) { const ctx: Context = { auth, - legacyAnonymous, access: { has: (actor, permission) => actor.kind !== "service" && actor.sub !== null && permissions.includes(permission), current: () => null, @@ -55,19 +51,8 @@ const code = (promise: Promise) => ) describe("policy", () => { - it("refuses anonymous calls unless LEGACY_ANONYMOUS allows them", async () => { - expect(await code(caller({ kind: "anonymous" }).lookup())).toBe("UNAUTHORIZED") - expect(await code(caller({ kind: "anonymous" }, [], "deny").lookup())).toBe("UNAUTHORIZED") - expect(await caller({ kind: "anonymous" }, [], "allow").lookup()).toEqual({ actor: null }) - }) - - it("refuses anonymous calls to token-only procedures even while allowed", async () => { - expect(await code(caller({ kind: "anonymous" }, [], "allow").tokenOnly())).toBe("UNAUTHORIZED") - }) - - it("treats a direct caller without auth as anonymous", async () => { + it("refuses a direct caller without a token", async () => { expect(await code(router.createCaller({}).lookup())).toBe("UNAUTHORIZED") - expect(await router.createCaller({ legacyAnonymous: "allow" }).lookup()).toEqual({ actor: null }) }) it("passes on authentication rejections", async () => { @@ -80,7 +65,6 @@ describe("policy", () => { expect(await caller(token(bot, ["backend:tg:read"])).lookup()).toEqual({ actor: bot }) expect(await code(caller(token(bot, ["backend:tg:ingest"])).lookup())).toBe("FORBIDDEN") expect(await caller(token(bot, ["backend:public:read"])).multiScope()).toBe("ok") - expect(await caller(token(bot, ["backend:tg:read"])).tokenOnly()).toEqual({ client: "telegram-bot" }) }) it("requires both the scope and the person's permission", async () => { diff --git a/tests/idp-router.test.ts b/tests/idp-router.test.ts index cefc457..25a13b3 100644 --- a/tests/idp-router.test.ts +++ b/tests/idp-router.test.ts @@ -82,7 +82,7 @@ beforeAll(async () => { ;({ appRouter } = await import("@/routers")) }) -type ProcedureDef = { _def: { meta?: { policy?: unknown; tokenOnly?: boolean } } } +type ProcedureDef = { _def: { meta?: { policy?: unknown } } } function procedures(): Array<[string, ProcedureDef]> { return Object.entries( @@ -103,11 +103,9 @@ describe("app router", () => { const policyOf = (path: string) => procedures().find(([name]) => name === path)?.[1]._def.meta expect(policyOf("tg.auditLog.record")).toMatchObject({ policy: { service: { scope: "backend:tg:audit" } }, - tokenOnly: true, }) expect(policyOf("tg.users.getCreationHistory")).toMatchObject({ policy: { user: { scope: "backend:admin", permission: "tg:users:read" } }, - tokenOnly: true, }) expect(policyOf("tg.grants.create")?.policy).toEqual({ user: { scope: "backend:admin", permission: "tg:grants:manage" }, @@ -124,7 +122,6 @@ describe("app router", () => { }) expect(policyOf("azure.members.setAssocNumber")).toMatchObject({ policy: { user: { scope: "backend:admin", permission: "azure:members:write" } }, - tokenOnly: true, }) expect(policyOf("azure.groups.getAll")?.policy).toEqual({ user: { scope: "backend:admin", permission: "azure:groups:read" }, @@ -140,35 +137,18 @@ describe("app router", () => { }) }) - it("refuses anonymous callers on every write that records its actor, whatever LEGACY_ANONYMOUS says", () => { - const tokenOnly = new Set( - procedures() - .filter(([, procedure]) => procedure._def.meta?.tokenOnly) - .map(([path]) => path) - ) - for (const path of [ - "tg.grants.create", - "tg.grants.interrupt", - "tg.groups.leaveChat", - "groups.labels.create", - "groups.labels.modify", - "web.associations.addAssociation", - "web.associations.editAssociation", - "web.associations.editAssociationLinks", - "web.faqs.addFaqs", - "web.faqs.addFaqsCategory", - "web.faqs.editFaqs", - "web.faqs.editFaqsCategory", - "web.projects.addProject", - "web.projects.editProject", - "web.guides_matricole.addGuide", - ]) - expect(tokenOnly).toContain(path) + it("refuses every procedure to a caller without a token", async () => { + const caller = appRouter.createCaller({}) as unknown as Record + for (const [path] of procedures()) { + const procedure = path.split(".").reduce((node, key) => (node as Record)[key], caller as unknown) + await expect((procedure as (input?: unknown) => Promise)(), path).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }) + } }) it("no longer has the procedures RFC v3 Phase 5 removes", () => { const paths = procedures().map(([path]) => path) - // azure.members.setAssocNumber is back as a token-only `dashboard` procedure, asserted above. const removed = paths.filter((path) => /^(auth|test)\.|^tg\.(permissions|link|groupLabels)\.|^tg\.auditLog\.create$/.test(path) ) @@ -204,8 +184,8 @@ describe("app router", () => { ]) }) - it("refuses anonymous label writes even while LEGACY_ANONYMOUS allows anonymous reads", async () => { - const caller = appRouter.createCaller({ legacyAnonymous: "allow" }) + it("refuses label writes without a token", async () => { + const caller = appRouter.createCaller({}) await expect(caller.groups.labels.create({ label: "students", color: "#3b82f6" })).rejects.toMatchObject({ code: "UNAUTHORIZED", }) @@ -229,7 +209,6 @@ describe("groups.search", () => { dashboard: () => appRouter.createCaller({ auth: { kind: "token", actor: admin, scopes: new Set(["backend:admin"]) }, access }) .groups.search, - anonymous: () => appRouter.createCaller({ legacyAnonymous: "allow" }).groups.search, } /** Whether the last query filtered out hidden groups. */ const hidesHidden = () => { @@ -254,15 +233,14 @@ describe("groups.search", () => { expect(hidesHidden()).toBe(true) }) - it("still gives hidden groups to the dashboard and to anonymous callers while allowed", async () => { - for (const caller of [callers.dashboard(), callers.anonymous()]) { - await caller.getAll() - expect(hidesHidden()).toBe(false) - await caller.search({ showHidden: true }) - expect(hidesHidden()).toBe(false) - await caller.search({}) - expect(hidesHidden()).toBe(true) - } + it("still gives hidden groups to the dashboard", async () => { + const caller = callers.dashboard() + await caller.getAll() + expect(hidesHidden()).toBe(false) + await caller.search({ showHidden: true }) + expect(hidesHidden()).toBe(false) + await caller.search({}) + expect(hidesHidden()).toBe(true) }) it("refuses service tokens without backend:public:read", async () => { @@ -321,7 +299,7 @@ describe("tg.access.resolve and me.access", () => { }) }) - it("refuses anonymous callers on the new procedures", async () => { + it("refuses the new procedures without a token", async () => { const caller = appRouter.createCaller({}) await expect(caller.tg.access.resolve({ telegramIds: ["123"] })).rejects.toMatchObject({ code: "UNAUTHORIZED" }) await expect(caller.me.access()).rejects.toMatchObject({ code: "UNAUTHORIZED" }) diff --git a/tests/people-router.test.ts b/tests/people-router.test.ts index 1e8e130..c0abeb2 100644 --- a/tests/people-router.test.ts +++ b/tests/people-router.test.ts @@ -33,7 +33,7 @@ function context( } } describe("People tRPC contract", () => { - it("refuses anonymous, service, Telegram and wrong-scope callers before reaching People", async () => { + it("refuses tokenless, service, Telegram and wrong-scope callers before reaching People", async () => { const h = harness() const r = makeRouter(h.service) await expect(r.createCaller({}).me.get()).rejects.toMatchObject({ code: "UNAUTHORIZED" }) @@ -60,14 +60,13 @@ describe("People tRPC contract", () => { expect(Object.keys(rows[0]).sort()).toEqual(["account", "id", "member", "profile"]) expect(rows[0].profile.personalEmail).toBeNull() }) - it("mounts every procedure used by admin #100 with token-only policy", () => { + it("mounts every procedure used by admin #100 with a dashboard policy", () => { const r = makeRouter(harness().service) const procedures = (r as unknown as { _def: { procedures: Record } })._def .procedures const defs = Object.entries(procedures) expect(defs.length).toBe(62) - for (const [, proc] of defs) - expect(proc._def.meta).toMatchObject({ policy: { user: { scope: "backend:admin" } }, tokenOnly: true }) + for (const [, proc] of defs) expect(proc._def.meta).toMatchObject({ policy: { user: { scope: "backend:admin" } } }) for (const path of [ "persons.list", "members.list",