diff --git a/server/drizzle/0013_credentials_one_live_key.sql b/server/drizzle/0013_credentials_one_live_key.sql new file mode 100644 index 00000000..120ef123 --- /dev/null +++ b/server/drizzle/0013_credentials_one_live_key.sql @@ -0,0 +1,53 @@ +-- One live credential per (kind, provider, key_id). +-- +-- A database that ran the rotation this repository shipped before may already +-- hold more than one, because that rotation inserted the new credential and +-- then revoked the previous one as two separate statements: when the revoke +-- failed the caller saw an error and wrote nothing further, so the new row was +-- left live and nothing was repointed at it. CREATE UNIQUE INDEX would fail +-- outright on those rows, so they are reconciled first. +-- +-- Which duplicate survives matters, and the newest is the wrong answer. In the +-- failure above it is the new row that nothing references, while the older one +-- is still named by the MCP server, the connection or the agent that was using +-- it. Keeping the newest would revoke the credential actually in use and leave +-- the deployment authenticating with nothing. So a referenced row wins, and +-- only where nothing is referenced does the newest win. +-- +-- The three tables below are every one that names a credential: `mcp_servers` +-- for a server's own token and OAuth client, `mcp_user_credentials` for one +-- person's connection to a server, and an agent's `configuration`. The old +-- connector tables named one too and were dropped in `0011`. +-- +-- On a database with no duplicates this rewrites no rows. +WITH referenced AS ( + SELECT "credential_id"::text AS "id" + FROM "mcp_servers" + WHERE "credential_id" IS NOT NULL + UNION + SELECT "credential_id"::text + FROM "mcp_user_credentials" + WHERE "credential_id" IS NOT NULL + UNION + SELECT "configuration" -> 'auth' ->> 'credentialId' + FROM "agents" + WHERE "configuration" -> 'auth' ->> 'credentialId' IS NOT NULL +), +ranked AS ( + SELECT c."id", + row_number() OVER ( + PARTITION BY c."kind", c."provider", c."key_id" + ORDER BY (r."id" IS NOT NULL) DESC, c."created_at" DESC, c."id" DESC + ) AS "rank" + FROM "credentials" c + LEFT JOIN referenced r ON r."id" = c."id"::text + WHERE c."revoked_at" IS NULL +) +UPDATE "credentials" AS c + SET "revoked_at" = now(), + "updated_at" = now() + FROM ranked + WHERE ranked."id" = c."id" + AND ranked."rank" > 1; +--> statement-breakpoint +CREATE UNIQUE INDEX "credentials_active_key_idx" ON "credentials" USING btree ("kind","provider","key_id") WHERE "credentials"."revoked_at" IS NULL; diff --git a/server/drizzle/meta/0013_snapshot.json b/server/drizzle/meta/0013_snapshot.json new file mode 100644 index 00000000..5daac600 --- /dev/null +++ b/server/drizzle/meta/0013_snapshot.json @@ -0,0 +1,2308 @@ +{ + "id": "8e4fc308-2e9b-4b30-ba94-16c002796cde", + "prevId": "2f106c72-9245-471b-bda8-1974a749990c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": ["channel_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": ["user_id", "role"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": ["user_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": ["component_name", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": ["component_name", "function_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": ["server_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": ["server_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": ["kind", "ref", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index a6877854..f1697653 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1787444747113, "tag": "0012_truncate_is_not_a_way_around_append_only", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1787444747114, + "tag": "0013_credentials_one_live_key", + "breakpoints": true } ] } diff --git a/server/src/agents/auth-header.ts b/server/src/agents/auth-header.ts index 83346903..6c36a298 100644 --- a/server/src/agents/auth-header.ts +++ b/server/src/agents/auth-header.ts @@ -1,4 +1,5 @@ import { + type CredentialExecutor, type CredentialSecretReader, type CredentialStore, decryptSecret, @@ -58,16 +59,52 @@ export async function storeAgentAuth(input: { agentId: string; header: string; value: string; + /** + * The credential the agent's configuration currently names, if any. Present + * on an edit that replaces the key, absent on first creation. + * + * Whether it is still live is checked here rather than assumed, because the + * configuration keeps naming a credential an administrator has revoked from + * the Credentials page. Rotating onto a revoked row is refused by the vault, + * so trusting the reference would leave that agent's key impossible to + * replace: every later edit would fail on the same stale id. + */ + previousCredentialId?: string; + /** + * The transaction to write in, when the caller has one. + * + * Agent edits are a transaction over `agents` and `agent_profiles`, and the + * credential is part of that same change. Written outside it, the key would + * commit even where the edit that asked for it rolled back. + */ + executor?: CredentialExecutor; }): Promise { - const credential = await input.store.create({ - kind: "agent", + const value = { + kind: "agent" as const, provider: "ag-ui", keyId: input.agentId, // The header name is metadata precisely because it is not a secret; keeping it here makes the // vault row self-describing for later audit. metadata: { header: input.header }, encryptedValue: await encryptSecret(input.encryptionKey, input.value), - }); + }; + + // A live previous credential is rotated, so the agent never holds two. Any + // other case inserts: the partial unique index permits it, because there is + // no live row for this agent to collide with. + const rotates = + input.previousCredentialId !== undefined && + (await input.store.isLive(input.previousCredentialId, input.executor)); + + const credential = rotates + ? await input.store.rotate( + { + ...value, + previousCredentialId: input.previousCredentialId as string, + }, + input.executor, + ) + : await input.store.create(value, input.executor); return { header: input.header, credentialId: credential.id }; } @@ -86,18 +123,25 @@ export async function storeAgentAuth(input: { * * Never throws. The new key is already stored and the Bot already works; a vault that would not * accept the revocation is worth saying loudly and is not worth failing an edit that has succeeded. + * + * Takes the caller's transaction where there is one, and must be given it whenever the caller holds + * a lock on the row being retired. On a pooled connection the revoke is a second session competing + * with the caller's own open transaction: it waits for a lock only that transaction can release, and + * the transaction cannot commit while it is awaiting this call. Nothing breaks that, so the edit + * hangs to the statement timeout and the timeout is then reported here as a key still live. */ export async function retireReplacedKey( store: Pick, previous: Record, next: Record, + executor?: CredentialExecutor, ): Promise { const before = credentialIdOf(previous); const after = credentialIdOf(next); if (!before || before === after) return; try { - await store.revoke(before); + await store.revoke(before, executor); } catch (error) { console.error( JSON.stringify({ diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index 5b055fdd..8d14bdd4 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -329,6 +329,7 @@ export function createAgentProfileStore( agentId: id, header: input.auth.header, value: input.auth.value, + executor: transaction, }), } : {}), @@ -387,25 +388,28 @@ export function createAgentProfileStore( agentId: id, header: input.auth.header, value: input.auth.value, + // An agent that already has a live key is being edited, not + // first-created, so the vault rotates rather than inserting + // a second live row for the same agent id. + previousCredentialId: authFromConfiguration( + row?.configuration, + )?.credentialId, + executor: transaction, }), } : {}), }; /* - * The key this one replaces is retired. + * The key this one replaces is already retired, by the rotation above. * - * Rotating a key is the standard answer to a suspected leak, and without this it did not - * answer it: the old credential stayed in the vault, decryptable and still valid, and - * nothing listed it or could reach it. "Is that leaked key still live" was yes. The - * credentials table also grew one unrevoked secret per edit per Bot. - * - * After the new one is stored, so a failure here leaves the Bot working with a key too - * many rather than with none. + * `storeAgentAuth` locks the previous credential, revokes it and inserts the replacement + * inside this transaction, so there is nothing left here to retire. A second revoke from + * outside the transaction would wait on the row lock this transaction is holding and never + * be released, because the transaction cannot commit until the call it is awaiting + * returns: editing a Bot's key would hang until the statement timed out, and the timeout + * would then be reported as a key that is still live. */ - if (input.auth && vault) { - await retireReplacedKey(vault.store, previous, configuration); - } await transaction .update(agents) .set({ name: input.name, configuration, updatedAt }) @@ -513,6 +517,10 @@ export function createAgentProfileStore( vault.store, (row?.configuration ?? {}) as Record, {}, + // In this transaction, so the key is retired exactly when the deletion is. On its own + // connection the revoke would commit even where the delete rolled back, leaving a Bot + // that still exists and can no longer reach its endpoint. + transaction, ); } }, diff --git a/server/src/audit.ts b/server/src/audit.ts index 3be5e620..48d0638e 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -36,6 +36,15 @@ export const auditEventTypes = [ "configuration.changed", "credential.created", "credential.rotated", + /** + * A rotation the vault refused, and why. + * + * Recorded because the refusals are the interesting ones. A rotation aimed at a key other than the + * one the credential belongs to, or at a credential already revoked, is either a caller with a bug + * or somebody trying to retire a key they were not asked to retire, and neither left a trace while + * only the successes were written. + */ + "credential.rotation_refused", "credential.revoked", "connector.sync_succeeded", "connector.sync_failed", diff --git a/server/src/credentials.ts b/server/src/credentials.ts index af3cc901..ea14fd3a 100644 --- a/server/src/credentials.ts +++ b/server/src/credentials.ts @@ -35,15 +35,64 @@ type StoredCredential = { revokedAt: Date | null; }; +export type CredentialStoreValue = { + kind: CredentialKind; + provider: string; + keyId: string; + metadata: Record; + encryptedValue: string; +}; + +type Transaction = Parameters[0]>[0]; + +/** + * Where a credential write runs. + * + * A caller already inside a transaction passes it here, so the write joins that + * transaction rather than opening one of its own on a second pooled connection. + * Two connections would mean the credential committing separately from the + * change that asked for it, and, since the caller is usually holding row locks + * by then, a pool with nothing spare to hand out deadlocks instead. See the + * note on `max` in `db/client.ts`. + */ +export type CredentialExecutor = + | Pick + | Pick; + export type CredentialStore = { - create: (value: { - kind: CredentialKind; - provider: string; - keyId: string; - metadata: Record; - encryptedValue: string; - }) => Promise; - revoke: (id: string) => Promise; + create: ( + value: CredentialStoreValue, + executor?: CredentialExecutor, + ) => Promise; + /** + * Replace one credential with another, atomically. + * + * Retiring the old secret and storing the new one are one decision, so they + * are one write. Two separate calls cannot be made safe from the outside: + * an `UPDATE` can commit and still throw on the way back, and no compensating + * revoke survives the process being killed between them. + * + * Without an executor this opens its own transaction. With one it runs inside + * the caller's, and is atomic with whatever else that transaction is doing. + */ + rotate: ( + input: CredentialStoreValue & { previousCredentialId: string }, + executor?: CredentialExecutor, + ) => Promise; + revoke: (id: string, executor?: CredentialExecutor) => Promise; + /** Whether this credential exists and has not been revoked. */ + isLive: (id: string, executor?: CredentialExecutor) => Promise; + /** + * The live credential for a key, if this deployment holds one. + * + * At most one can exist, which is what `credentials_active_key_idx` + * enforces, so a caller about to store a secret for a key can ask whether it + * is replacing something rather than finding out from a failed insert. + */ + findLiveByKey: ( + key: { kind: CredentialKind; provider: string; keyId: string }, + executor?: CredentialExecutor, + ) => Promise<{ id: string } | null>; }; export type CredentialSecretReader = { @@ -164,8 +213,8 @@ export function createCredentialStore( CredentialStatusReader & ModelCredentialSecretReader { return { - create: async (value) => { - const [credential] = await database + create: async (value, executor = database) => { + const [credential] = await executor .insert(credentials) .values(value) .returning({ id: credentials.id, revokedAt: credentials.revokedAt }); @@ -175,19 +224,128 @@ export function createCredentialStore( } return credential; }, - revoke: async (id) => { + rotate: async (input, executor) => { + const write = async (transaction: CredentialExecutor) => { + /** + * The previous credential, locked for the rest of the transaction. + * + * Two replicas rotating the same secret would otherwise both read it + * as live and both act; the second waits here and then finds it + * revoked. Its identity is read alongside its state so a rotation + * aimed at the wrong id is refused rather than silently retiring a + * secret the caller never named. + */ + const [previous] = await transaction + .select({ + revokedAt: credentials.revokedAt, + kind: credentials.kind, + provider: credentials.provider, + keyId: credentials.keyId, + }) + .from(credentials) + .where(eq(credentials.id, input.previousCredentialId)) + .for("update"); + if (!previous) { + throw new Error("Previous credential was not found"); + } + if (previous.revokedAt) { + throw new Error("Previous credential is already revoked"); + } + if ( + previous.kind !== input.kind || + previous.provider !== input.provider || + previous.keyId !== input.keyId + ) { + throw new Error( + "Previous credential does not match the input's kind, provider or keyId", + ); + } + + // Revoke, then insert. Both orders are invisible from outside the + // transaction, and this one never holds two live rows for one key even + // in the middle of it, which is the invariant a uniqueness constraint + // on live credentials would later depend on. + const revokedAt = new Date(); + const [revoked] = await transaction + .update(credentials) + .set({ revokedAt, updatedAt: revokedAt }) + .where( + and( + eq(credentials.id, input.previousCredentialId), + isNull(credentials.revokedAt), + ), + ) + .returning({ revokedAt: credentials.revokedAt }); + if (!revoked?.revokedAt) { + // Ruled out by the lock above under contention; reaching here means + // the row was deleted outright between the two statements, which + // aborts the transaction and leaves nothing committed. + throw new Error("Previous credential was not found"); + } + + const [inserted] = await transaction + .insert(credentials) + .values({ + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + metadata: input.metadata, + encryptedValue: input.encryptedValue, + }) + .returning({ + id: credentials.id, + revokedAt: credentials.revokedAt, + }); + if (!inserted) { + throw new Error("Credential could not be stored"); + } + + return inserted; + }; + + // A caller already in a transaction has its own atomicity to keep, and + // the credential belongs to it rather than to a transaction of its own. + return executor ? write(executor) : database.transaction(write); + }, + revoke: async (id, executor = database) => { const revokedAt = new Date(); - const [credential] = await database + const [credential] = await executor .update(credentials) .set({ revokedAt, updatedAt: revokedAt }) - .where(eq(credentials.id, id)) + // Only a live row is stamped. Without the guard a second revoke would + // overwrite the first one's timestamp and report success, so a caller + // could not tell retiring a credential from finding it already gone. + .where(and(eq(credentials.id, id), isNull(credentials.revokedAt))) .returning({ revokedAt: credentials.revokedAt }); if (!credential?.revokedAt) { - throw new Error("Credential was not found"); + throw new Error("Credential was not found or already revoked"); } return credential.revokedAt; }, + isLive: async (id, executor = database) => { + const [credential] = await executor + .select({ id: credentials.id }) + .from(credentials) + .where(and(eq(credentials.id, id), isNull(credentials.revokedAt))); + + return credential !== undefined; + }, + findLiveByKey: async ({ kind, provider, keyId }, executor = database) => { + const [credential] = await executor + .select({ id: credentials.id }) + .from(credentials) + .where( + and( + eq(credentials.kind, kind), + eq(credentials.provider, provider), + eq(credentials.keyId, keyId), + isNull(credentials.revokedAt), + ), + ); + + return credential ?? null; + }, readSecret: async (id) => { const [credential] = await database .select({ @@ -286,10 +444,36 @@ async function persistCredential( }; } +/** + * Store a credential for a key. + * + * A key holds one live credential, so storing a second one for a key that + * already has one is a replacement rather than an addition, and it is carried + * out as a rotation: the two writes are atomic and the trail records + * `credential.rotated` naming what was replaced. + * + * This is the shape the product asks for. The Credentials page offers Add and + * Revoke and has no rotate control, so replacing a model key is done by adding + * one for the same provider and keyId. Refusing that would leave no way to + * replace a key at all, and inserting it would raise a bare unique violation + * from `credentials_active_key_idx`. + */ export async function createCredential( service: CredentialService, input: CredentialInput, ): Promise { + const existing = await service.store.findLiveByKey({ + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + }); + if (existing) { + return rotateCredential(service, { + ...input, + previousCredentialId: existing.id, + }); + } + const credential = await persistCredential(service, input); await recordAuditEvent(service.auditStore, { @@ -310,14 +494,56 @@ export async function createCredential( export async function rotateCredential( service: CredentialService, input: CredentialInput & { previousCredentialId: string }, -) { - const credential = await persistCredential(service, input); - await service.store.revoke(input.previousCredentialId); +): Promise { + // Encryption happens before the transaction opens, so no database connection + // is held while it runs. The store then performs both writes atomically, and + // a failure leaves the vault as it was, which is why the success event below + // is written only once that has returned. + let stored: StoredCredential; + try { + stored = await service.store.rotate({ + previousCredentialId: input.previousCredentialId, + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + metadata: input.metadata, + encryptedValue: await encryptSecret( + service.encryptionKey, + input.plaintext, + ), + }); + } catch (error) { + /* + * A refused rotation is worth a row of its own. + * + * The vault refuses one aimed at a credential that is already revoked, one aimed at a credential + * that does not exist, and one whose key does not match the credential it names. Each of those is + * either a caller with a bug or an attempt to retire a key the caller was not asked to retire, + * and each of them left nothing behind while only successes were recorded. + * + * Written outside the transaction that has just rolled back, so the row survives the failure it + * describes. The reason is the vault's own message and never the secret, which never left this + * function. + */ + await recordAuditEvent(service.auditStore, { + eventType: "credential.rotation_refused", + targetType: "credential", + targetId: input.previousCredentialId, + actorUserId: input.actorUserId, + payload: { + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + reason: error instanceof Error ? error.message : String(error), + }, + }); + throw error; + } await recordAuditEvent(service.auditStore, { eventType: "credential.rotated", targetType: "credential", - targetId: credential.id, + targetId: stored.id, actorUserId: input.actorUserId, payload: { previousCredentialId: input.previousCredentialId, @@ -327,7 +553,14 @@ export async function rotateCredential( }, }); - return credential; + return { + id: stored.id, + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + metadata: input.metadata, + revokedAt: stored.revokedAt, + }; } export async function revokeCredential( diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index 570147ab..35dc9ac1 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -316,17 +316,28 @@ export const channelAgents = pgTable( (table) => [primaryKey({ columns: [table.channelId, table.agentId] })], ); -export const credentials = pgTable("credentials", { - id: uuid("id").primaryKey().defaultRandom(), - kind: credentialKind("kind").notNull(), - provider: text("provider").notNull(), - encryptedValue: text("encrypted_value").notNull(), - keyId: text("key_id").notNull(), - metadata: jsonb("metadata").notNull(), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - createdAt: createdAt(), - updatedAt: updatedAt(), -}); +export const credentials = pgTable( + "credentials", + { + id: uuid("id").primaryKey().defaultRandom(), + kind: credentialKind("kind").notNull(), + provider: text("provider").notNull(), + encryptedValue: text("encrypted_value").notNull(), + keyId: text("key_id").notNull(), + metadata: jsonb("metadata").notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + // At most one live credential per (kind, provider, key_id). Revoked rows are + // excluded so history is preserved, and two replicas racing to rotate the + // same secret cannot both insert a live row. + uniqueIndex("credentials_active_key_idx") + .on(table.kind, table.provider, table.keyId) + .where(sql`${table.revokedAt} IS NULL`), + ], +); export const auditEvents = pgTable( "audit_events", diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5e71844a..16b0426d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -271,6 +271,11 @@ export type PluginStoreOptions = { * client belongs to the server registration and a refresh token belongs to a connection, so both * are written by the code that owns those acts — otherwise the first of two calls can succeed and * the second fail, leaving a secret in the vault that nothing points at and nobody knows to revoke. + * + * `revoke` is part of it because a key holds at most one live credential now. `removeServer` + * retires the server's token, and the two write paths here replace rather than add, so re-adding a + * server or re-authorizing a connection does not meet its own leftover on + * `credentials_active_key_idx`. */ credentials: CredentialSecretReader & CredentialStore; encryptionKey: string; @@ -603,7 +608,63 @@ export function createPluginStore(options: PluginStoreOptions) { return added; }, + /** + * Remove a server, and stop its token being live. + * + * The token is keyed `mcp-` and nothing else revokes it, so + * leaving it behind means re-adding the same server meets its own + * abandoned row on `credentials_active_key_idx`. It is revoked rather + * than deleted, because the vault keeps revoked rows for audit. + * + * The revoke goes first. These are two writes on two tables and the + * store exposes no transaction that spans both, so the order decides + * what a failure between them leaves: revoke-then-delete leaves a server + * whose token no longer works and which removing again will finish off, + * while delete-then-revoke leaves a live token no server references and + * no operation can reach. + */ async removeServer(serverId: string, by: string): Promise { + const [existing] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + + /** + * Whether that token is still live, read rather than inferred from a + * thrown error, so a token a previous attempt already revoked, or one + * whose row is gone entirely, is skipped while a database fault still + * propagates and leaves the server row in place to be removed again. + * + * Two queries rather than a join because `mcp_servers.credential_id` is + * `text` and `credentials.id` is `uuid`, so the two columns do not + * compare without a cast. + */ + const [live] = existing?.credentialId + ? await database + .select({ id: credentialRows.id }) + .from(credentialRows) + .where( + and( + eq(credentialRows.id, existing.credentialId), + isNull(credentialRows.revokedAt), + ), + ) + : []; + + if (live) { + await credentials.revoke(live.id); + await recordAuditEvent(auditStore, { + eventType: "credential.revoked", + targetType: "credential", + targetId: live.id, + payload: { + actor: by, + reason: "mcp_server_removed", + server: serverId, + }, + }); + } + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); await recordAuditEvent(auditStore, { eventType: "configuration.changed", @@ -1006,29 +1067,39 @@ export function createPluginStore(options: PluginStoreOptions) { ); } - const stored = await credentials.create({ - kind: "mcp_oauth_client", + const key = { + kind: "mcp_oauth_client" as const, provider: input.serverId, keyId: `oauth-client-${input.serverId}`, + }; + const value = { + ...key, metadata: { server: input.serverId, clientId: input.client.clientId }, encryptedValue: await encryptSecret( encryptionKey, JSON.stringify(input.client), ), - }); + }; + + /* + * Re-registering a client replaces the one before it, in one transaction. + * + * A key holds at most one live credential, so inserting a second for this server would be + * refused by `credentials_active_key_idx` rather than leaving the orphan it used to leave. + * The question is asked of the key and not of `row.credentialId`, because the server row keeps + * naming a credential an administrator has revoked from the Credentials page: the pointer can + * be stale where the key is not, and it is the key the index constrains. + */ + const live = await credentials.findLiveByKey(key); + const stored = live + ? await credentials.rotate({ ...value, previousCredentialId: live.id }) + : await credentials.create(value); await database .update(mcpServers) .set({ credentialId: stored.id, updatedAt: new Date() }) .where(eq(mcpServers.id, input.serverId)); - if (row.credentialId) { - await credentials.revoke(row.credentialId).catch(() => { - // A previous client that cannot be revoked must not stop the new one taking effect. The - // pointer has already moved, so nothing reaches the old row; it is a tidiness failure. - }); - } - await recordAuditEvent(auditStore, { eventType: "mcp.oauth_client_registered", targetType: "mcp_server", @@ -1069,13 +1140,28 @@ export function createPluginStore(options: PluginStoreOptions) { ) .limit(1); - const stored = await credentials.create({ - kind: "mcp_user_token", + const key = { + kind: "mcp_user_token" as const, provider: input.serverId, keyId: input.userId, + }; + /* + * Reconnecting replaces this person's token for this server, in one transaction. + * + * `credentials_active_key_idx` holds one live credential per key, so a second insert for the + * same person and server would be refused. Asked of the key rather than of `previous`, because + * the connection row can name a credential that has already been revoked while the key itself + * is free, and it is the key the index constrains. + */ + const live = await credentials.findLiveByKey(key); + const value = { + ...key, metadata: { server: input.serverId, scope: input.scope }, encryptedValue: await encryptSecret(encryptionKey, input.refreshToken), - }); + }; + const stored = live + ? await credentials.rotate({ ...value, previousCredentialId: live.id }) + : await credentials.create(value); await database .insert(mcpUserCredentials) @@ -1094,12 +1180,6 @@ export function createPluginStore(options: PluginStoreOptions) { }, }); - if (previous) { - await credentials.revoke(previous.credentialId).catch(() => { - // Same reasoning as above: the pointer has moved, so this is tidiness rather than access. - }); - } - await recordAuditEvent(auditStore, { eventType: "mcp.account_connected", targetType: "mcp_server", diff --git a/server/tests/agent-auth-header.test.ts b/server/tests/agent-auth-header.test.ts new file mode 100644 index 00000000..3c57a97a --- /dev/null +++ b/server/tests/agent-auth-header.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { storeAgentAuth } from "../src/agents/auth-header"; +import type { CredentialStore } from "../src/credentials"; + +const key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + +/** + * An agent already has a credential when its bearer token is being edited. + * `credentials_active_key_idx` refuses two live rows for the same agent, so + * the edit path has to rotate the vault row rather than insert a duplicate. + * These tests pin which method the module reaches for in each case. + */ + +describe("storeAgentAuth", () => { + function fakeStore(options: { + live?: Set; + calls: string[]; + rotated?: unknown[]; + }): CredentialStore { + const live = options.live ?? new Set(); + return { + create: async () => { + options.calls.push("create"); + return { id: "credential-new", revokedAt: null }; + }, + rotate: async (input) => { + options.calls.push("rotate"); + options.rotated?.push(input); + return { id: "credential-rotated", revokedAt: null }; + }, + revoke: async () => new Date(), + isLive: async (id) => live.has(id), + findLiveByKey: async () => null, + }; + } + + test("creates a fresh credential when the agent has none yet", async () => { + const calls: string[] = []; + + const auth = await storeAgentAuth({ + store: fakeStore({ calls }), + encryptionKey: key, + agentId: "agent-1", + header: "Authorization", + value: "Bearer abc", + }); + + expect(calls).toEqual(["create"]); + expect(auth).toEqual({ + header: "Authorization", + credentialId: "credential-new", + }); + }); + + test("rotates the credential the agent already holds", async () => { + const calls: string[] = []; + const rotated: unknown[] = []; + + const auth = await storeAgentAuth({ + store: fakeStore({ calls, rotated, live: new Set(["credential-old"]) }), + encryptionKey: key, + agentId: "agent-1", + header: "Authorization", + value: "Bearer new", + previousCredentialId: "credential-old", + }); + + expect(calls).toEqual(["rotate"]); + const [call] = rotated as [ + { previousCredentialId: string; kind: string; keyId: string }, + ]; + expect(call.previousCredentialId).toBe("credential-old"); + expect(call.kind).toBe("agent"); + expect(call.keyId).toBe("agent-1"); + expect(JSON.stringify(rotated)).not.toContain("Bearer new"); + expect(auth.credentialId).toBe("credential-rotated"); + }); + + test("creates when the credential the agent names has been revoked", async () => { + // An administrator can revoke an agent's key from the Credentials page, + // and nothing repoints the agent's configuration when they do. Rotating + // onto that revoked row is refused by the vault, so trusting the reference + // would leave the key impossible to replace: every later edit would fail + // on the same stale id. + const calls: string[] = []; + + const auth = await storeAgentAuth({ + store: fakeStore({ calls, live: new Set() }), + encryptionKey: key, + agentId: "agent-1", + header: "Authorization", + value: "Bearer replacement", + previousCredentialId: "credential-revoked", + }); + + expect(calls).toEqual(["create"]); + expect(auth.credentialId).toBe("credential-new"); + }); +}); diff --git a/server/tests/agent-key-rotation.integration.test.ts b/server/tests/agent-key-rotation.integration.test.ts new file mode 100644 index 00000000..24f81e6c --- /dev/null +++ b/server/tests/agent-key-rotation.integration.test.ts @@ -0,0 +1,164 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { createCredentialStore } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { agentProfiles, agents, credentials, users } from "../src/db/schema"; +import { createAgentProfileStore } from "../src/agents/profile-store"; +import type { AgentActor } from "../src/agents/profile-types"; + +/** + * Editing a Bot's key, against a real database. + * + * The rotation tests elsewhere hand `storeAgentAuth` a fake store, which can answer any call + * instantly and holds no locks. That is enough to prove which vault call is made and useless for + * proving the call can be made at all: every failure this file exists to catch is a lock taken by + * one connection and waited for by another, and a fake has neither. + * + * The pool is pinned to one connection deliberately. A second vault write on its own connection is + * a second session competing with the transaction the edit is already inside, and at `max: 1` it + * cannot even be handed a connection until that transaction ends — which it never will, because the + * transaction is awaiting the call. The edit hangs until something times it out. At the driver's + * default pool the same shape survives as a row-lock wait instead, slower to hit and identical in + * effect, so one connection is the honest setting for the question being asked. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + { max: 1 }, +); + +const encryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const store = createCredentialStore(database); +const profiles = createAgentProfileStore(database, undefined, { + store, + encryptionKey, +}); + +const suite = randomUUID().slice(0, 8); +const actor: AgentActor = { id: `user_${suite}`, role: "admin" }; +const created: string[] = []; + +/** An edit that hangs is the failure, so the wait is bounded and the bound is the assertion. */ +const DEADLINE_MS = 5_000; + +async function within(label: string, work: Promise): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `${label} did not return within ${DEADLINE_MS}ms, which is what a vault write on a second connection looks like from inside the transaction that is holding the only one`, + ), + ), + DEADLINE_MS, + ); + }); + try { + return await Promise.race([work, deadline]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function liveKeysFor(agentId: string) { + return database + .select({ id: credentials.id }) + .from(credentials) + .where( + and( + eq(credentials.kind, "agent"), + eq(credentials.keyId, agentId), + isNull(credentials.revokedAt), + ), + ); +} + +beforeAll(async () => { + await database.insert(users).values({ + id: actor.id, + email: `${actor.id}@openbot.test`, + name: "Key rotation tester", + emailVerified: true, + }); + + const profile = await profiles.create(actor, { + name: `key rotation ${suite}`, + title: "Tester", + roleDescription: "Holds a key that gets replaced.", + visibility: "private", + endpoint: "https://example.invalid/agent", + auth: { header: "Authorization", value: "first-secret" }, + }); + created.push(profile.id); +}); + +afterAll(async () => { + if (created.length) { + await database + .delete(agentProfiles) + .where(inArray(agentProfiles.agentId, created)); + await database.delete(agents).where(inArray(agents.id, created)); + await database + .delete(credentials) + .where(inArray(credentials.keyId, created)); + } + await database.delete(users).where(eq(users.id, actor.id)); + await database.$client.end(); +}); + +describe("editing a Bot's key", () => { + test("returns, and leaves exactly one live credential", async () => { + const [agentId] = created; + expect(await liveKeysFor(agentId)).toHaveLength(1); + const [before] = await liveKeysFor(agentId); + + await within( + "the edit", + profiles.update(actor, agentId, { + name: `key rotation ${suite}`, + title: "Tester", + roleDescription: "Holds a key that gets replaced.", + visibility: "private", + endpoint: "https://example.invalid/agent", + auth: { header: "Authorization", value: "second-secret" }, + }), + ); + + const live = await liveKeysFor(agentId); + expect(live).toHaveLength(1); + expect(live[0]?.id).not.toBe(before?.id); + }); + + test("the credential it replaced is revoked, not merely unreferenced", async () => { + const [agentId] = created; + const rows = await database + .select({ id: credentials.id, revokedAt: credentials.revokedAt }) + .from(credentials) + .where( + and(eq(credentials.kind, "agent"), eq(credentials.keyId, agentId)), + ); + + expect(rows).toHaveLength(2); + expect(rows.filter((row) => row.revokedAt === null)).toHaveLength(1); + expect(rows.filter((row) => row.revokedAt !== null)).toHaveLength(1); + }); + + test("deleting the Bot retires the key it was still holding", async () => { + const profile = await profiles.create(actor, { + name: `key deletion ${suite}`, + title: "Tester", + roleDescription: "Holds a key until it is deleted.", + visibility: "private", + endpoint: "https://example.invalid/agent", + auth: { header: "Authorization", value: "only-secret" }, + }); + created.push(profile.id); + + expect(await liveKeysFor(profile.id)).toHaveLength(1); + await within("the deletion", profiles.softDelete(actor, profile.id)); + expect(await liveKeysFor(profile.id)).toHaveLength(0); + }); +}); diff --git a/server/tests/credentials.test.ts b/server/tests/credentials.test.ts index de7acd1a..b343f969 100644 --- a/server/tests/credentials.test.ts +++ b/server/tests/credentials.test.ts @@ -59,7 +59,12 @@ describe("credential encryption", () => { stored.push(value); return { id: "credential-1", revokedAt: null }; }, + rotate: async () => { + throw new Error("nothing live to replace, so create is the path"); + }, revoke: async () => new Date(), + isLive: async () => false, + findLiveByKey: async () => null, }, auditStore: { insert: async (event) => { @@ -102,12 +107,17 @@ describe("credential encryption", () => { }); test("rotates then revokes credentials without returning plaintext", async () => { + const rotated: { previousCredentialId: string }[] = []; const revoked: string[] = []; const audited: unknown[] = []; const service = { encryptionKey: key, store: { - create: async () => ({ id: "credential-new", revokedAt: null }), + create: async () => ({ id: "credential-unused", revokedAt: null }), + rotate: async (input: { previousCredentialId: string }) => { + rotated.push(input); + return { id: "credential-new", revokedAt: null }; + }, revoke: async (id: string) => { revoked.push(id); return new Date("2026-08-13T12:00:00.000Z"); @@ -131,13 +141,75 @@ describe("credential encryption", () => { }); await revokeCredential(service, "credential-new", "admin"); - expect(revoked).toEqual(["credential-old", "credential-new"]); + // The rotation retires the previous credential inside the store's own + // transaction, so the service makes no separate revoke call of its own. + expect(rotated.map((call) => call.previousCredentialId)).toEqual([ + "credential-old", + ]); + expect(revoked).toEqual(["credential-new"]); + expect(JSON.stringify(rotated)).not.toContain("new-openai-secret"); expect(JSON.stringify(audited)).not.toContain("new-openai-secret"); expect( audited.map((event) => (event as { eventType: string }).eventType), ).toEqual(["credential.rotated", "credential.revoked"]); }); + test("records the refusal when the rotation fails, and never a success", async () => { + // The store's rotate is one transaction, so a failure commits nothing and + // no row may say a rotation happened. The refusal itself is recorded + // though: a rotation aimed at a revoked credential, at one that does not + // exist, or at a key other than the credential's own is either a caller + // with a bug or an attempt to retire somebody else's key, and it used to + // leave nothing behind. The caller still sees the original cause. + const audited: unknown[] = []; + const service = { + encryptionKey: key, + store: { + create: async () => { + throw new Error("create is not part of a rotation"); + }, + rotate: async () => { + throw new Error("Previous credential is already revoked"); + }, + revoke: async () => { + throw new Error("revoke is not part of a failed rotation"); + }, + }, + auditStore: { + insert: async (event: unknown) => { + audited.push(event); + }, + }, + }; + + await expect( + rotateCredential(service, { + previousCredentialId: "credential-old", + kind: "model", + provider: "openai", + keyId: "primary", + metadata: {}, + plaintext: "new-openai-secret", + actorUserId: "admin", + }), + ).rejects.toThrow("Previous credential is already revoked"); + + expect(audited).toEqual([ + { + eventType: "credential.rotation_refused", + targetType: "credential", + targetId: "credential-old", + actorUserId: "admin", + payload: { + kind: "model", + provider: "openai", + keyId: "primary", + reason: "Previous credential is already revoked", + }, + }, + ]); + }); + test("decrypts only an active credential for server-side use", async () => { const encryptedValue = await encryptSecret(key, "connector-secret"); @@ -215,44 +287,23 @@ describe("model credential resolution", () => { }); describe("model credential store lookup", () => { - test("selects the newest active matching model credential with id as the timestamp tie-breaker", async () => { - const matchingOldId = randomUUID(); - const matchingLowerId = "00000000-0000-4000-8000-000000000001"; - const matchingHigherId = "00000000-0000-4000-8000-000000000002"; + test("selects the live matching model credential and ignores the rest", async () => { + // A key holds one live credential, which `credentials_active_key_idx` + // enforces, so the lookup never has more than one candidate to choose + // between. What is under test is the filter: the rows that do not match on + // kind, provider or keyId, and the revoked row for this very key, all have + // to be passed over. + const activeId = randomUUID(); const ignoredIds = [randomUUID(), randomUUID(), randomUUID(), randomUUID()]; - const allIds = [ - matchingOldId, - matchingLowerId, - matchingHigherId, - ...ignoredIds, - ]; - credentialIds.push(...allIds); + credentialIds.push(activeId, ...ignoredIds); await database.insert(credentials).values([ { - id: matchingOldId, - kind: "model", - provider: "openai", - keyId: "openai-api-key", - encryptedValue: "old-matching-value", - metadata: {}, - createdAt: new Date("2026-01-01T00:00:00.000Z"), - }, - { - id: matchingLowerId, + id: activeId, kind: "model", provider: "openai", keyId: "openai-api-key", - encryptedValue: "lower-id-value", - metadata: {}, - createdAt: new Date("2026-02-01T00:00:00.000Z"), - }, - { - id: matchingHigherId, - kind: "model", - provider: "openai", - keyId: "openai-api-key", - encryptedValue: "higher-id-value", + encryptedValue: "active-value", metadata: {}, createdAt: new Date("2026-02-01T00:00:00.000Z"), }, @@ -291,7 +342,7 @@ describe("model credential store lookup", () => { encryptedValue: "revoked-value", metadata: {}, revokedAt: new Date("2026-03-01T00:00:00.000Z"), - createdAt: new Date("2026-03-01T00:00:00.000Z"), + createdAt: new Date("2026-01-01T00:00:00.000Z"), }, ]); @@ -300,7 +351,239 @@ describe("model credential store lookup", () => { provider: "openai", keyId: "openai-api-key", }), - ).resolves.toEqual({ encryptedValue: "higher-id-value" }); + ).resolves.toEqual({ encryptedValue: "active-value" }); + }); +}); + +describe("credential store rotation", () => { + test("retires the previous credential and stores the new one together", async () => { + const store = createCredentialStore(database); + const previousId = randomUUID(); + const keyId = `rotation-atomic-${previousId}`; + credentialIds.push(previousId); + await database.insert(credentials).values({ + id: previousId, + kind: "model", + provider: "openai", + keyId, + encryptedValue: "initial", + metadata: {}, + }); + + const rotated = await store.rotate({ + previousCredentialId: previousId, + kind: "model", + provider: "openai", + keyId, + metadata: {}, + encryptedValue: "rotated", + }); + credentialIds.push(rotated.id); + + const rows = await database + .select({ + id: credentials.id, + encryptedValue: credentials.encryptedValue, + revokedAt: credentials.revokedAt, + }) + .from(credentials) + .where(eq(credentials.keyId, keyId)); + const byId = Object.fromEntries(rows.map((row) => [row.id, row])); + + expect(byId[previousId]?.revokedAt).not.toBeNull(); + expect(byId[rotated.id]?.revokedAt).toBeNull(); + expect(byId[rotated.id]?.encryptedValue).toBe("rotated"); + }); + + test("refuses a previous credential that is already revoked, and stores nothing", async () => { + const store = createCredentialStore(database); + const previousId = randomUUID(); + const keyId = `rotation-revoked-${previousId}`; + credentialIds.push(previousId); + await database.insert(credentials).values({ + id: previousId, + kind: "model", + provider: "openai", + keyId, + encryptedValue: "initial", + metadata: {}, + revokedAt: new Date("2026-08-01T00:00:00.000Z"), + }); + + await expect( + store.rotate({ + previousCredentialId: previousId, + kind: "model", + provider: "openai", + keyId, + metadata: {}, + encryptedValue: "rotated", + }), + ).rejects.toThrow("already revoked"); + + const rows = await database + .select({ id: credentials.id }) + .from(credentials) + .where(eq(credentials.keyId, keyId)); + expect(rows.map((row) => row.id)).toEqual([previousId]); + }); + + test("refuses a previous credential that does not exist, and stores nothing", async () => { + const store = createCredentialStore(database); + const missing = randomUUID(); + const keyId = `rotation-missing-${missing}`; + + await expect( + store.rotate({ + previousCredentialId: missing, + kind: "model", + provider: "openai", + keyId, + metadata: {}, + encryptedValue: "orphan-if-broken", + }), + ).rejects.toThrow("not found"); + + // Nothing was written, so the failed rotation left no credential behind + // for this key at all. + const rows = await database + .select({ id: credentials.id }) + .from(credentials) + .where(eq(credentials.keyId, keyId)); + expect(rows).toEqual([]); + }); + + test("refuses a rotation aimed at a different kind, provider or keyId", async () => { + // Rotating one credential with another's identity would retire a secret + // the caller never named and leave its key with nothing live, which is a + // worse outcome than a raised error. + const store = createCredentialStore(database); + const previousId = randomUUID(); + const previousKey = `mismatch-previous-${previousId}`; + credentialIds.push(previousId); + await database.insert(credentials).values({ + id: previousId, + kind: "model", + provider: "openai", + keyId: previousKey, + encryptedValue: "previous", + metadata: {}, + }); + + await expect( + store.rotate({ + previousCredentialId: previousId, + kind: "model", + provider: "openai", + keyId: `mismatch-input-${previousId}`, + metadata: {}, + encryptedValue: "would-retire-the-wrong-secret", + }), + ).rejects.toThrow("does not match"); + + const [after] = await database + .select({ revokedAt: credentials.revokedAt }) + .from(credentials) + .where(eq(credentials.id, previousId)); + expect(after?.revokedAt).toBeNull(); + }); + + test("refuses to revoke a credential that is already revoked", async () => { + const store = createCredentialStore(database); + const id = randomUUID(); + credentialIds.push(id); + await database.insert(credentials).values({ + id, + kind: "model", + provider: "openai", + keyId: `revoke-guard-${id}`, + encryptedValue: "one", + metadata: {}, + revokedAt: new Date("2026-08-01T00:00:00.000Z"), + }); + + await expect(store.revoke(id)).rejects.toThrow("already revoked"); + }); +}); + +describe("one live credential per key", () => { + test("the index refuses a second live credential for the same key", async () => { + const first = randomUUID(); + const second = randomUUID(); + credentialIds.push(first, second); + const keyId = `unique-active-${first}`; + await database.insert(credentials).values({ + id: first, + kind: "model", + provider: "openai", + keyId, + encryptedValue: "first", + metadata: {}, + }); + + await expect( + (async () => + database.insert(credentials).values({ + id: second, + kind: "model", + provider: "openai", + keyId, + encryptedValue: "second", + metadata: {}, + }))(), + ).rejects.toThrow(); + }); + + test("storing a credential for a key that has one replaces it", async () => { + // The Credentials page offers Add and Revoke and no rotate control, so + // replacing a key is done by adding one for the same provider and keyId. + // That has to retire what is there rather than raise a unique violation. + const audited: { eventType: string; targetId?: string }[] = []; + const service = { + encryptionKey: key, + store: createCredentialStore(database), + auditStore: { + insert: async (event: { eventType: string; targetId?: string }) => { + audited.push(event); + }, + }, + }; + const keyId = `replace-on-add-${randomUUID()}`; + + const first = await createCredential(service, { + kind: "model", + provider: "openai", + keyId, + metadata: {}, + plaintext: "first-secret", + actorUserId: "admin", + }); + credentialIds.push(first.id); + + const second = await createCredential(service, { + kind: "model", + provider: "openai", + keyId, + metadata: {}, + plaintext: "second-secret", + actorUserId: "admin", + }); + credentialIds.push(second.id); + + expect(second.id).not.toBe(first.id); + const rows = await database + .select({ id: credentials.id, revokedAt: credentials.revokedAt }) + .from(credentials) + .where(eq(credentials.keyId, keyId)); + const byId = Object.fromEntries(rows.map((row) => [row.id, row])); + expect(byId[first.id]?.revokedAt).not.toBeNull(); + expect(byId[second.id]?.revokedAt).toBeNull(); + + // The trail says what happened: an addition, then a replacement naming it. + expect(audited.map((event) => event.eventType)).toEqual([ + "credential.created", + "credential.rotated", + ]); }); }); diff --git a/server/tests/plugin-credential-rotation.integration.test.ts b/server/tests/plugin-credential-rotation.integration.test.ts new file mode 100644 index 00000000..22ebbeb4 --- /dev/null +++ b/server/tests/plugin-credential-rotation.integration.test.ts @@ -0,0 +1,220 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import type { ActionPolicy } from "../src/computer/policy"; +import { createCredentialStore } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { + credentials, + mcpServers, + mcpUserCredentials, + users, +} from "../src/db/schema"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL } from "./support/database"; + +/** + * Registering a client twice, and connecting twice, against a real vault. + * + * Both paths used to insert a second live credential for a key and revoke the first afterwards, on + * a best-effort basis. `credentials_active_key_idx` refuses the second insert outright, so the + * question is no longer whether an orphan is left behind but whether the path still works at all. + * A stubbed vault cannot answer that: the index is a database object, and only a database enforces + * it. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const ENCRYPTION_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; +const suite = randomUUID().slice(0, 8); +const serverId = "google-drive"; +const personId = `plugin_rotation_person_${suite}`; + +const store = createPluginStore({ + database, + auditStore: createAuditStore(database), + credentials: createCredentialStore(database), + encryptionKey: ENCRYPTION_KEY, + policy: () => policy, +}); + +let clientBefore: string | null = null; +let serverExisted = false; + +async function rowsFor( + kind: "mcp_oauth_client" | "mcp_user_token", + keyId: string, +) { + return database + .select({ id: credentials.id, revokedAt: credentials.revokedAt }) + .from(credentials) + .where( + and( + eq(credentials.kind, kind), + eq(credentials.provider, serverId), + eq(credentials.keyId, keyId), + ), + ); +} + +function live(rows: T[]) { + return rows.filter((row) => row.revokedAt === null); +} + +beforeAll(async () => { + await database + .insert(users) + .values({ + id: personId, + email: `${personId}@openbot.test`, + name: personId, + emailVerified: false, + }) + .onConflictDoNothing(); + + const [existing] = await database + .select({ id: mcpServers.id, credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + serverExisted = existing !== undefined; + clientBefore = existing?.credentialId ?? null; + + await database + .insert(mcpServers) + .values({ + id: serverId, + title: "Google Drive", + vendor: "Google", + url: "https://www.googleapis.com/drive/v3", + provenance: "first-party", + }) + .onConflictDoNothing(); + + // This run's own client key, so a deployment's real Drive registration is never touched. + await database + .update(credentials) + .set({ revokedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(credentials.kind, "mcp_oauth_client"), + eq(credentials.provider, serverId), + isNull(credentials.revokedAt), + ), + ); +}); + +afterAll(async () => { + await database + .delete(mcpUserCredentials) + .where(eq(mcpUserCredentials.userId, personId)); + /* + * The pointer is dropped before the rows are, and put back only if what it named survives. + * + * `mcp_servers.credential_id` is a real foreign key, so a credential this run registered cannot be + * deleted while the server still names it. Restoring first is not enough either: on a database + * where an earlier run of this file left the pointer on one of its own rows, restoring puts it + * straight back onto a row about to be deleted. + */ + await database + .update(mcpServers) + .set({ credentialId: null }) + .where(eq(mcpServers.id, serverId)); + const mine = [ + ...(await rowsFor("mcp_user_token", personId)), + ...(await rowsFor("mcp_oauth_client", `oauth-client-${serverId}`)), + ].map((row) => row.id); + if (mine.length) { + await database.delete(credentials).where(inArray(credentials.id, mine)); + } + await database.delete(users).where(eq(users.id, personId)); + if (clientBefore) { + const [survivor] = await database + .select({ id: credentials.id }) + .from(credentials) + .where(eq(credentials.id, clientBefore)); + if (survivor) { + await database + .update(mcpServers) + .set({ credentialId: clientBefore }) + .where(eq(mcpServers.id, serverId)); + } + } + if (!serverExisted) { + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); + } + await database.$client.end(); +}); + +describe("registering an OAuth client twice", () => { + test("replaces the client rather than meeting the index", async () => { + await store.registerOAuthClient({ + serverId, + client: { clientId: `client-one-${suite}`, clientSecret: "one" }, + by: personId, + }); + const first = live( + await rowsFor("mcp_oauth_client", `oauth-client-${serverId}`), + ); + expect(first).toHaveLength(1); + + await store.registerOAuthClient({ + serverId, + client: { clientId: `client-two-${suite}`, clientSecret: "two" }, + by: personId, + }); + + const all = await rowsFor("mcp_oauth_client", `oauth-client-${serverId}`); + expect(live(all)).toHaveLength(1); + expect(live(all)[0]?.id).not.toBe(first[0]?.id); + expect(all.filter((row) => row.revokedAt !== null).length).toBeGreaterThan( + 0, + ); + + const [server] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + expect(server?.credentialId).toBe(live(all)[0]?.id as string); + }); +}); + +describe("reconnecting the same person to the same server", () => { + test("replaces their token rather than meeting the index", async () => { + await store.recordConnection({ + serverId, + userId: personId, + refreshToken: "refresh-one", + scope: "https://www.googleapis.com/auth/drive.readonly", + }); + const first = live(await rowsFor("mcp_user_token", personId)); + expect(first).toHaveLength(1); + + await store.recordConnection({ + serverId, + userId: personId, + refreshToken: "refresh-two", + scope: "https://www.googleapis.com/auth/drive.readonly", + }); + + const all = await rowsFor("mcp_user_token", personId); + expect(live(all)).toHaveLength(1); + expect(live(all)[0]?.id).not.toBe(first[0]?.id); + + const [connection] = await database + .select({ credentialId: mcpUserCredentials.credentialId }) + .from(mcpUserCredentials) + .where( + and( + eq(mcpUserCredentials.serverId, serverId), + eq(mcpUserCredentials.userId, personId), + ), + ); + expect(connection?.credentialId).toBe(live(all)[0]?.id as string); + }); +}); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index bc84270a..8677b447 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -8,6 +8,7 @@ import { TEST_POOL } from "./support/database"; import { agents, auditEvents, + credentials as credentialRows, mcpServers, mcpTools, pluginGrants, @@ -55,19 +56,30 @@ let serverWasAlreadyConfigured = false; */ let toolWasAlreadyAdvertised = false; +const revokedCredentialIds: string[] = []; +const issuedCredentialIds: string[] = []; const store = createPluginStore({ database, auditStore: createAuditStore(database), credentials: { // No credential is ever read in these tests, because every call is refused before the vault. readSecret: async () => null, - // Nor written. Loud rather than absent: a call reaching either of these would mean this file had - // started exercising something it does not claim to, and a silent no-op would hide that. + // Nor created. Loud rather than absent: a call reaching this would mean this file had started + // exercising something it does not claim to, and a silent no-op would hide that. create: async () => { throw new Error("this suite does not write credentials"); }, - revoke: async () => { - throw new Error("this suite does not revoke credentials"); + // `removeServer` does revoke: it retires the token the server was configured with so a re-add + // does not collide on `credentials_active_key_idx`. The stamp goes to the real row, because + // `removeServer` reads liveness from the table before deciding whether to revoke at all. + revoke: async (id: string) => { + const revokedAt = new Date(); + await database + .update(credentialRows) + .set({ revokedAt, updatedAt: revokedAt }) + .where(eq(credentialRows.id, id)); + revokedCredentialIds.push(id); + return revokedAt; }, }, encryptionKey: "x".repeat(44), @@ -170,6 +182,9 @@ afterAll(async () => { } await database.delete(agents).where(eq(agents.id, holderId)); await database.delete(agents).where(eq(agents.id, strangerId)); + for (const id of issuedCredentialIds) { + await database.delete(credentialRows).where(eq(credentialRows.id, id)); + } }); describe("a grant is the permission", () => { @@ -378,6 +393,82 @@ describe("a boundary written about the browser does not refuse tool calls", () = }); }); +describe("removing an MCP server", () => { + test("revokes the credential the server was configured with", async () => { + // Without this, the credential row stays live after the server row is + // gone, and re-adding the same server would unique-violate on + // `credentials_active_key_idx`. The audit trail also carries the + // revocation with `reason: mcp_server_removed`. + const removalServerId = `removal-target-${suite}`; + revokedCredentialIds.length = 0; + const [credentialRow] = await database + .insert(credentialRows) + .values({ + kind: "mcp", + provider: removalServerId, + keyId: `mcp-${removalServerId}`, + encryptedValue: "{}", + metadata: {}, + }) + .returning({ id: credentialRows.id }); + const credentialId = credentialRow?.id; + if (!credentialId) throw new Error("credential row was not created"); + issuedCredentialIds.push(credentialId); + await database.insert(mcpServers).values({ + id: removalServerId, + title: "removal target", + vendor: "test", + url: "https://example.invalid/mcp", + credentialId, + provenance: "custom", + }); + + await store.removeServer(removalServerId, "admin@openbot.local"); + + expect(revokedCredentialIds).toEqual([credentialId]); + const [row] = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, removalServerId)); + expect(row).toBeUndefined(); + const audit = await database + .select({ + eventType: auditEvents.eventType, + payload: auditEvents.payload, + }) + .from(auditEvents) + .where( + and( + eq(auditEvents.targetType, "credential"), + eq(auditEvents.targetId, credentialId), + ), + ); + expect(audit).toHaveLength(1); + expect(audit[0]?.eventType).toBe("credential.revoked"); + expect((audit[0]?.payload as { reason?: string })?.reason).toBe( + "mcp_server_removed", + ); + // Audit is append-only in Postgres; leaving the row is fine because + // `credentialId` is suite-scoped, so re-runs never collide. + }); + + test("does not call revoke when the server had no credential", async () => { + const removalServerId = `removal-target-nocred-${suite}`; + revokedCredentialIds.length = 0; + await database.insert(mcpServers).values({ + id: removalServerId, + title: "removal target no cred", + vendor: "test", + url: "https://example.invalid/mcp", + provenance: "custom", + }); + + await store.removeServer(removalServerId, "admin@openbot.local"); + + expect(revokedCredentialIds).toEqual([]); + }); +}); + describe("the trail can be read by a second reader", () => { test("a refusal names the bot, the server and the tool in queryable JSON", async () => { const [row] = await database diff --git a/server/tests/plugin-user-credential.integration.test.ts b/server/tests/plugin-user-credential.integration.test.ts index f4adad2a..08ca92b4 100644 --- a/server/tests/plugin-user-credential.integration.test.ts +++ b/server/tests/plugin-user-credential.integration.test.ts @@ -155,8 +155,35 @@ const store = createPluginStore({ }, }); +/** + * Retire whatever this key currently holds, the way the product now does. + * + * These fixtures insert straight into the vault rather than going through the store, and a key holds + * at most one live credential since `credentials_active_key_idx`. Re-registering a client or + * reconnecting a person is a replacement, so the row it replaces is revoked first; without this the + * second test to call either helper meets the index instead of the behaviour it came to check. + */ +async function retireLive( + kind: "mcp_oauth_client" | "mcp_user_token", + keyId: string, +) { + const revokedAt = new Date(); + await database + .update(credentials) + .set({ revokedAt, updatedAt: revokedAt }) + .where( + and( + eq(credentials.kind, kind), + eq(credentials.provider, serverId), + eq(credentials.keyId, keyId), + isNull(credentials.revokedAt), + ), + ); +} + /** Register the deployment's OAuth client, which is what `mcp_servers.credential_id` holds. */ async function registerClient() { + await retireLive("mcp_oauth_client", "oauth-client"); const [credential] = await database .insert(credentials) .values({ @@ -180,6 +207,7 @@ async function registerClient() { } async function connect(userId: string, refreshToken: string) { + await retireLive("mcp_user_token", userId); const [credential] = await database .insert(credentials) .values({