diff --git a/.changeset/workspace-writes-admin-only.md b/.changeset/workspace-writes-admin-only.md new file mode 100644 index 0000000000..823f5a3e61 --- /dev/null +++ b/.changeset/workspace-writes-admin-only.md @@ -0,0 +1,52 @@ +--- +"@executor-js/sdk": minor +"@executor-js/api": minor +"@executor-js/plugin-graphql": minor +"@executor-js/plugin-mcp": minor +"@executor-js/plugin-openapi": minor +--- + +**Workspace writes now require an administrator** + +Executor bindings accept `orgWrites: "allowed" | "denied" | "request"`. +Request-aware hosts use `"request"` and bind `CurrentOrgWriteAccess` from the +authenticated principal for each request. An approval, decline, cancellation, +or form response also rebinds the paused execution to the resumer's current +access. Browser approvals derive access from the authenticated browser user's +live organization membership when that user posts the decision, rather than +from the earlier MCP request waiting for it or the user's global role. Self-host +uses the same Better Auth membership lookup for ordinary requests and browser +decisions. A demotion before either kind of resume therefore takes effect +before the paused execution can reach a workspace-write sink. + +`Principal` now declares its role model explicitly: organization-backed hosts +carry `orgRoleModel: "organization"` and an optional normalized admin/member +role, while hosts without roles carry `orgRoleModel: "none"` and cannot also +carry an organization role. Missing role data under the organization model +fails closed, including legacy persisted MCP session metadata. Cloud derives +roles from WorkOS memberships and self-host derives them from Better Auth. + +Members may still read and execute shared workspace resources and perform +operational maintenance such as token refresh and tool-catalog synchronization. +User-requested workspace mutations now return `OrgWriteDeniedError` (HTTP 403): +workspace connections and reconnects, organization OAuth clients and connect +flows, tool policies, and integration add/update/replace/remove/health-check +operations. Personal connection management remains available. + +Pasted connection credentials, OAuth client secrets, OAuth connection tokens, +and dependent tool discovery run only after the outermost transaction commits +their row, including when a plugin wraps creation in `ctx.transaction`. Each +committed row records unique provider item references owned by that write +attempt. Reads resolve only those recorded references, so the post-commit +window fails closed with a retryable incomplete-write error and can never +resolve a predecessor's credential. A process crash leaves detectable missing +references; a later executor incarnation can atomically replace and retry a +stranded pasted connection, while OAuth client and connection retries replace +their rows through their existing update paths. + +If credential persistence fails while the process remains alive, row and +provider compensation restore the prior state where possible and surface +incomplete cleanup explicitly. Best-effort cleanup can leave inert orphaned +attempt items, but an attempt never shares an item reference with a successor, +eliminating the former successor-clobber interval without requiring provider +compare-and-set support. diff --git a/apps/cloud/drizzle/0017_lush_thunderbolts.sql b/apps/cloud/drizzle/0017_lush_thunderbolts.sql new file mode 100644 index 0000000000..f2dc4a7e9e --- /dev/null +++ b/apps/cloud/drizzle/0017_lush_thunderbolts.sql @@ -0,0 +1,2 @@ +ALTER TABLE "connection" ADD COLUMN "credential_write" json;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "credential_write" json; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0017_snapshot.json b/apps/cloud/drizzle/meta/0017_snapshot.json new file mode 100644 index 0000000000..9c00f4e65a --- /dev/null +++ b/apps/cloud/drizzle/meta/0017_snapshot.json @@ -0,0 +1,1504 @@ +{ + "id": "42251aa3-ae24-4010-ac65-9f41e26cdc20", + "prevId": "f22622e4-cfd6-4224-b653-050244dc511e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_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": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index f97c045a55..375397ceca 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1788164746073, "tag": "0016_oauth_client_token_auth", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1788287088210, + "tag": "0017_lush_thunderbolts", + "breakpoints": true } ] } diff --git a/apps/cloud/src/api.request-scope.node.test.ts b/apps/cloud/src/api.request-scope.node.test.ts index 727d05ef13..e2bac9143e 100644 --- a/apps/cloud/src/api.request-scope.node.test.ts +++ b/apps/cloud/src/api.request-scope.node.test.ts @@ -291,6 +291,8 @@ const stackProbePrincipal: Principal = { name: "Request Scope", avatarUrl: null, roles: ["admin"], + orgRoleModel: "organization", + orgRole: "admin", }; /** diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 3ba01a61b4..d92ffe723e 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -98,6 +98,10 @@ describe("protected API key auth", () => { name: null, avatarUrl: null, roles: [], + // The stub membership carries no role slug — normalization FAILS + // CLOSED to plain member, so the executor binds workspace writes off. + orgRoleModel: "organization", + orgRole: "member", }); }), ); diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts index dbf35e1c2c..b330ecdd70 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -119,6 +119,10 @@ describe("protected JWT (device-login) auth", () => { name: null, avatarUrl: null, roles: [], + // The stub membership carries no role slug — normalization FAILS + // CLOSED to plain member, so the executor binds workspace writes off. + orgRoleModel: "organization", + orgRole: "member", }); }), ); diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index a6f9a9e5d3..ae91bd35a4 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -120,6 +120,7 @@ const requireSelectedOrganization = Effect.gen(function* () { return { ...session, organizationId: org.id, + memberRole: org.memberRole, }; }); @@ -698,6 +699,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( { accountId: owner.accountId, organizationId: owner.organizationId, + orgRole: owner.memberRole, }, { action: payload.action, diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts index 073dfacb32..5aceb2ab1a 100644 --- a/apps/cloud/src/auth/organization.ts +++ b/apps/cloud/src/auth/organization.ts @@ -88,7 +88,14 @@ export const authorizeOrganization = (userId: string, organizationId: string) => ); if (!active) return null; - return yield* resolveOrganization(organizationId); + const org = yield* resolveOrganization(organizationId); + // The membership row already names the caller's role — surface it + // normalized so identity resolution can bind the executor's workspace + // write permission without a second WorkOS call. WorkOS issues + // `admin` / `member`; anything unrecognized stays a plain member. + const roleSlug = (active as { readonly role?: { readonly slug?: string } }).role?.slug; + const memberRole: "admin" | "member" = roleSlug === "admin" ? "admin" : "member"; + return { ...org, memberRole }; }); // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 95742038f3..6abbffb7cb 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -154,6 +154,8 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => name: null, avatarUrl: null, roles: [], + orgRoleModel: "organization", + orgRole: org.memberRole, } satisfies Principal; }); @@ -253,6 +255,8 @@ export const resolveBearerAuth = ( name: null, avatarUrl: null, roles: [], + orgRoleModel: "organization", + orgRole: org.memberRole, } satisfies Principal; }); @@ -326,6 +330,8 @@ export const resolveSessionPrincipal = (request: Request) => name: sealedSessionDisplayName(session), avatarUrl: session.avatarUrl ?? null, roles: [], + orgRoleModel: "organization", + orgRole: org.memberRole, } satisfies Principal; }); diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 0ce950a2b6..0db709b884 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -57,6 +57,7 @@ export const connection = pgTable( template: text("template").notNull(), provider: text("provider").notNull(), item_ids: json("item_ids").notNull(), + credential_write: json("credential_write"), identity_label: text("identity_label"), description: text("description"), last_health: json("last_health"), @@ -98,6 +99,7 @@ export const oauth_client = pgTable( grant: text("grant").notNull(), client_id: text("client_id").notNull(), client_secret_item_id: text("client_secret_item_id"), + credential_write: json("credential_write"), token_endpoint_auth_method: text("token_endpoint_auth_method"), resource: text("resource"), origin_kind: text("origin_kind"), diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 0ec697c911..05c2107ed0 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -5,6 +5,8 @@ import { McpAuthProvider, jsonRpcErrorBody, defaultMcpResource, + orgWriteAccessForPrincipal, + withOrgWriteAccess, UNAVAILABLE_RETRY_AFTER_SECONDS, type AuthOutcome, type McpResource, @@ -17,6 +19,7 @@ import { withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { sessionOrgRoleMetadata } from "@executor-js/cloudflare/mcp/role-metadata"; import { classifyDurableObjectError, durableObjectFailureResponse, @@ -181,6 +184,7 @@ const propsForPrincipal = ( // "carried, and blank". ...(principal.organizationName ? { organizationName: principal.organizationName } : {}), ...(principal.organizationSlug ? { organizationSlug: principal.organizationSlug } : {}), + ...sessionOrgRoleMetadata(principal), userId: principal.accountId, elicitationMode: readElicitationMode(request), artifactsEnabled: readArtifactsEnabled(request), @@ -289,13 +293,16 @@ export const makeCloudMcpAgentHandler = () => { const resource = resourceFromPath(request); const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; - const forwarded = withVerifiedIdentityHeaders( - request, - { - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }, - resource, + const forwarded = withOrgWriteAccess( + withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + resource, + ), + orgWriteAccessForPrincipal(outcome.principal), ); const target = resource.kind === "toolkit" ? serveToolkit : serve; let response: Response; diff --git a/apps/cloud/src/mcp/auth-provider.test.ts b/apps/cloud/src/mcp/auth-provider.test.ts index bcd6009efa..1a88793d69 100644 --- a/apps/cloud/src/mcp/auth-provider.test.ts +++ b/apps/cloud/src/mcp/auth-provider.test.ts @@ -22,7 +22,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Layer, Predicate } from "effect"; -import { McpAuthProvider } from "@executor-js/host-mcp"; +import { McpAuthProvider, orgWriteAccessForPrincipal } from "@executor-js/host-mcp"; import { WorkOSError } from "../auth/errors"; import { cloudMcpAuthProviderLayer } from "./auth-provider"; @@ -74,7 +74,8 @@ const stubOrgAuthNoMembership = Layer.succeed(McpOrganizationAuth)({ // record, not just the id: the session props carry the org's name and slug so // the session DO never re-reads the row. const stubOrgAuthActive = Layer.succeed(McpOrganizationAuth)({ - authorize: () => Effect.succeed({ id: ORG_ID, name: "Stub Org", slug: "stub-org" }), + authorize: () => + Effect.succeed({ id: ORG_ID, name: "Stub Org", slug: "stub-org", memberRole: "admin" }), }); // A failure that is not a WorkOSError at all (e.g. the per-request DB layer @@ -163,6 +164,14 @@ describe("cloud MCP org-authorization classification", () => { const principal = Predicate.isTagged(outcome, "Authenticated") ? outcome.principal : null; expect(principal?.accountId).toBe(ACCOUNT_ID); expect(principal?.organizationId).toBe(ORG_ID); + expect(principal?.orgRoleModel).toBe("organization"); + expect(principal?.orgRole, "the live membership role reaches the MCP session").toBe("admin"); + const legacyAccess = principal + ? orgWriteAccessForPrincipal( + (({ orgRole: _orgRole, ...legacyMissingRole }) => legacyMissingRole)(principal), + ) + : null; + expect(legacyAccess).toBe("denied"); }), ); diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index 92384da164..054054a940 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -107,6 +107,8 @@ const principalFromToken = ( organizationId: organization.id, organizationName: organization.name, ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), + orgRoleModel: "organization", + orgRole: organization.memberRole, email: "", name: null, avatarUrl: null, diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index ee6bb8ca17..d14e6f4997 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -171,6 +171,7 @@ export type AuthorizedMcpOrganization = { readonly id: string; readonly name: string; readonly slug?: string; + readonly memberRole: "admin" | "member"; }; export class McpOrganizationAuth extends Context.Service< @@ -230,7 +231,14 @@ export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ organizationId ? authorizeOrganization(accountId, organizationId).pipe( Effect.map((org) => - org ? ({ id: org.id, name: org.name, slug: org.slug } as const) : null, + org + ? ({ + id: org.id, + name: org.name, + slug: org.slug, + memberRole: org.memberRole, + } as const) + : null, ), ) : Effect.succeed(null), diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index d50a568386..2646a785a0 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -347,7 +347,10 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { expect(meta.organizationName).toBe("Stored Org"); expect(meta.organizationSlug).toBe("stored-org"); + expect(meta.orgRole).toBe("member"); expect(store.calls(), "a restore reuses what it persisted").toBe(0); }); + it("does not let a stored admin role authorize legacy init props", async () => { + const store = countingConnectTimeoutStore(); + const { orgRole: _orgRole, ...legacyToken } = TOKEN; + + const meta = await Effect.runPromise( + resolveSessionMetaForToken(legacyToken, STORED).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + + expect(meta.orgRole).toBeUndefined(); + expect(store.calls()).toBe(0); + }); + + it("uses the fresh role when a warm session's principal is demoted", async () => { + const store = countingConnectTimeoutStore(); + const admin = await Effect.runPromise( + resolveSessionMetaForToken({ ...TOKEN, orgRole: "admin" }, STORED).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + const member = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, admin).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + + expect(admin.orgRole).toBe("admin"); + expect(member.orgRole).toBe("member"); + expect(store.calls()).toBe(0); + }); + it("reads the database only when nothing else names the org", async () => { const store = namingStore(); @@ -117,6 +154,7 @@ describe("resolveSessionMetaForToken", () => { ); expect(meta.organizationName).toBe("Database Org"); + expect(meta.orgRole).toBe("member"); expect(store.calls()).toBe(1); }); diff --git a/apps/cloud/src/mcp/session-meta.ts b/apps/cloud/src/mcp/session-meta.ts index a0bd1df81b..8ae64e1a69 100644 --- a/apps/cloud/src/mcp/session-meta.ts +++ b/apps/cloud/src/mcp/session-meta.ts @@ -28,6 +28,7 @@ import { Data, Effect, Predicate, Result, Schedule } from "effect"; import type { McpSessionInit, SessionMeta } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { sessionOrgRoleMetadata } from "@executor-js/cloudflare/mcp/role-metadata"; import { UserStoreService } from "../auth/context"; import { WorkOSClient } from "../auth/workos"; @@ -111,17 +112,23 @@ const failureReason = (failure: unknown): string => const metaFromIdentity = ( token: McpSessionInit, - organization: { readonly name: string; readonly slug?: string }, -): SessionMeta => ({ - organizationId: token.organizationId, - organizationName: organization.name, - ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), - userId: token.userId, - resource: token.resource, - elicitationMode: token.elicitationMode, - artifactsEnabled: token.artifactsEnabled, - searchToolsEnabled: token.searchToolsEnabled, -}); + organization: { + readonly name: string; + readonly slug?: string; + }, +): SessionMeta => { + return { + organizationId: token.organizationId, + organizationName: organization.name, + ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), + ...sessionOrgRoleMetadata(token), + userId: token.userId, + resource: token.resource, + elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + searchToolsEnabled: token.searchToolsEnabled, + }; +}; /** * Read the organization row, retrying only failures a retry can clear, and diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts index 8d4a75151c..1e1a7e00a7 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import { orgWriteAccessForPrincipal } from "@executor-js/host-mcp"; + import type { CloudflareConfig } from "../config"; import { principalFromAccessClaims } from "./cloudflare-access"; @@ -34,6 +36,9 @@ describe("principalFromAccessClaims", () => { it("grants admin when the email is in the allowlist", () => { const p = principalFromAccessClaims({ sub: "u", email: "ADMIN@example.com" }, config); expect(p.roles).toContain("admin"); + expect(p.orgRoleModel).toBe("organization"); + expect(p.orgRole).toBe("admin"); + expect(orgWriteAccessForPrincipal(p)).toBe("allowed"); }); it("gives a SERVICE TOKEN (common_name, no email/sub) a stable identity", () => { @@ -49,5 +54,8 @@ describe("principalFromAccessClaims", () => { it("defaults to member when there are no groups and no admin match", () => { const p = principalFromAccessClaims({ sub: "u", email: "nobody@other.com" }, config); expect(p.roles).toEqual(["member"]); + expect(p.orgRoleModel).toBe("organization"); + expect(p.orgRole).toBe("member"); + expect(orgWriteAccessForPrincipal(p)).toBe("denied"); }); }); diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index 7590245d40..b311256c2e 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -47,6 +47,8 @@ export const principalFromAccessClaims = ( name: typeof nameClaim === "string" ? nameClaim : commonName || null, avatarUrl: null, roles: isAdmin ? ["admin", ...groups] : groups.length > 0 ? groups : ["member"], + orgRoleModel: "organization", + orgRole: isAdmin ? "admin" : "member", }; }; @@ -78,6 +80,8 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { name: "Dev", avatarUrl: null, roles: ["admin"], + orgRoleModel: "organization", + orgRole: "admin", }; const verify = (request: Request): Effect.Effect => diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index a870277401..64ea2d4176 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -4,6 +4,8 @@ import { McpAuthProvider, jsonRpcErrorBody, defaultMcpResource, + orgWriteAccessForPrincipal, + withOrgWriteAccess, type AuthOutcome, type Principal, } from "@executor-js/host-mcp"; @@ -15,6 +17,7 @@ import { withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { sessionOrgRoleMetadata } from "@executor-js/cloudflare/mcp/role-metadata"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; @@ -78,6 +81,7 @@ const propsForPrincipal = ( return { session: { organizationId: principal.organizationId, + ...sessionOrgRoleMetadata(principal), userId: principal.accountId, elicitationMode: readElicitationMode(request), artifactsEnabled: readArtifactsEnabled(request), @@ -140,13 +144,16 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; - const forwarded = withVerifiedIdentityHeaders( - request, - { - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }, - defaultMcpResource, + const forwarded = withOrgWriteAccess( + withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + defaultMcpResource, + ), + orgWriteAccessForPrincipal(outcome.principal), ); return serve.fetch(forwarded, env, ctx); }; diff --git a/apps/host-cloudflare/src/mcp/index.ts b/apps/host-cloudflare/src/mcp/index.ts index 1de283c205..bcb9d74b22 100644 --- a/apps/host-cloudflare/src/mcp/index.ts +++ b/apps/host-cloudflare/src/mcp/index.ts @@ -1,7 +1,10 @@ import { Effect } from "effect"; import { decodeResumeResponse } from "@executor-js/host-mcp/browser-approval"; -import type { McpApprovalOwner } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import type { + McpApprovalOwner, + McpApprovalPrincipal, +} from "@executor-js/cloudflare/mcp/agent-durable-object"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; @@ -45,6 +48,10 @@ export const makeCloudflareApprovalHandler = ( const resume = RESUME_PATH.exec(pathname); if (resume && request.method === "POST") { + const approver: McpApprovalPrincipal = { + ...owner, + orgRole: principal.orgRole === "admin" ? "admin" : "member", + }; const raw = await Effect.runPromise( Effect.tryPromise({ try: () => request.json(), catch: () => null }).pipe( Effect.orElseSucceed(() => null), @@ -55,7 +62,7 @@ export const makeCloudflareApprovalHandler = ( const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval( decodeURIComponent(resume[2]!), - owner, + approver, response, ); if (result.status !== "ok") return jsonResponse({ error: "Paused execution not found" }, 404); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index ee3695491a..ba4f2ff861 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -17,6 +17,7 @@ import { type McpSessionInit, type SessionMeta, } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { sessionOrgRoleMetadata } from "@executor-js/cloudflare/mcp/role-metadata"; import { mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, @@ -126,6 +127,7 @@ export class McpSessionDO extends McpAgentSessionDOBase +export const isPrivileged = (role: string): boolean => role .split(",") .map((part) => part.trim()) diff --git a/apps/host-selfhost/src/auth/identity.ts b/apps/host-selfhost/src/auth/identity.ts index 932e90230c..a637b12a6a 100644 --- a/apps/host-selfhost/src/auth/identity.ts +++ b/apps/host-selfhost/src/auth/identity.ts @@ -2,7 +2,8 @@ import { Effect, Layer } from "effect"; import { IdentityProvider, Unauthorized } from "@executor-js/api/server"; -import { BetterAuth } from "./better-auth"; +import { isPrivileged } from "../admin/require-admin"; +import { BetterAuth, type BetterAuthHandle } from "./better-auth"; // --------------------------------------------------------------------------- // The self-host identity seam — the production implementation of the shared @@ -27,6 +28,28 @@ const bearerToken = (headers: Headers): string | undefined => { : undefined; }; +/** + * Resolve workspace-write authority from the caller's current membership in + * the self-host instance organization. Both ordinary API/MCP requests and the + * browser-decision adapter use this exact lookup so a role change takes effect + * at the mutation decision, without trusting the global Better Auth user role. + * Lookup failures fail closed to member authority. + */ +export const resolveSelfHostOrgRole = ( + betterAuth: BetterAuthHandle, + headers: Headers | Record, + organizationId: string, +): Effect.Effect<"admin" | "member"> => + Effect.tryPromise(() => + betterAuth.auth.api.getActiveMemberRole({ + headers, + query: { organizationId }, + }), + ).pipe( + Effect.orElseSucceed(() => null), + Effect.map((membership) => (membership && isPrivileged(membership.role) ? "admin" : "member")), + ); + // --------------------------------------------------------------------------- // The production IdentityProvider: resolve a request to a Better Auth session // and map it to a neutral Principal. Three credential shapes resolve here: @@ -42,20 +65,26 @@ const bearerToken = (headers: Headers): string | undefined => { export const betterAuthIdentityLayer: Layer.Layer = Layer.effect(IdentityProvider)( Effect.gen(function* () { - const { auth, organizationId, organizationName, organizationSlug } = yield* BetterAuth; + const betterAuth = yield* BetterAuth; + const { auth, organizationId, organizationName, organizationSlug } = betterAuth; return IdentityProvider.of({ authenticate: (request) => Effect.gen(function* () { let resolved = yield* Effect.promise(() => auth.api.getSession({ headers: request.headers }), ); + // The credential shape that resolved the session — the SAME headers + // are what the membership-role lookup below must present. + let sessionHeaders: Headers | Record = request.headers; if (!resolved) { const token = bearerToken(request.headers); if (token) { + const apiKeyHeaders = { "x-api-key": token }; resolved = yield* Effect.tryPromise({ - try: () => auth.api.getSession({ headers: { "x-api-key": token } }), + try: () => auth.api.getSession({ headers: apiKeyHeaders }), catch: () => "api-key session lookup failed", }).pipe(Effect.orElseSucceed(() => null)); + sessionHeaders = apiKeyHeaders; } } // No session resolved from any credential shape -> unauthenticated. @@ -66,6 +95,16 @@ export const betterAuthIdentityLayer: Layer.Layer role.trim()) .filter((role) => role.length > 0), + orgRoleModel: "organization", + orgRole, }; }), }); diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index abd07ba384..967d4255d3 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -11,6 +11,7 @@ import { type Principal, } from "@executor-js/host-mcp"; +import { isPrivileged } from "../admin/require-admin"; import { BetterAuth } from "../auth/better-auth"; import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org-path"; @@ -205,6 +206,24 @@ export const selfHostMcpAuth: Layer.Layer context.internalAdapter.findUserById(userId)); if (!user) return null; + // The workspace role, read from the INSTANCE org's membership row + // (an OAuth token carries no session, so the header-based + // `getActiveMemberRole` gate is out of reach — the adapter query + // answers the same question against the same table). FAIL CLOSED to + // "member": an infra fault demotes rather than escalates. + const membership = yield* Effect.promise(() => + context.adapter.findOne<{ readonly role?: string | null }>({ + model: "member", + where: [ + { field: "userId", value: userId }, + { field: "organizationId", value: organizationId }, + ], + }), + ).pipe(Effect.orElseSucceed(() => null)); + const orgRole = + membership?.role != null && isPrivileged(membership.role) + ? ("admin" as const) + : ("member" as const); return { accountId: user.id, // Single-org self-host: OAuth tokens carry no active org, so pin to @@ -216,6 +235,8 @@ export const selfHostMcpAuth: Layer.Layer - new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); const parseRoles = (role: string | null | undefined): ReadonlyArray => (role ?? "user") @@ -83,15 +87,23 @@ type BetterAuthSession = NonNullable< const principalFromSession = ( resolved: BetterAuthSession, betterAuth: BetterAuthHandle, -): Principal => ({ - accountId: resolved.user.id, - organizationId: resolved.session.activeOrganizationId ?? betterAuth.organizationId, - organizationName: betterAuth.organizationName, - email: resolved.user.email, - name: resolved.user.name ?? null, - avatarUrl: resolved.user.image ?? null, - roles: parseRoles(resolved.user.role ?? null), -}); + headers: Headers, +): Effect.Effect => { + const organizationId = resolved.session.activeOrganizationId ?? betterAuth.organizationId; + return resolveSelfHostOrgRole(betterAuth, headers, organizationId).pipe( + Effect.map((orgRole) => ({ + accountId: resolved.user.id, + organizationId, + organizationName: betterAuth.organizationName, + email: resolved.user.email, + name: resolved.user.name ?? null, + avatarUrl: resolved.user.image ?? null, + roles: parseRoles(resolved.user.role ?? null), + orgRoleModel: "organization" as const, + orgRole, + })), + ); +}; /** * Gate the browser-approval endpoints behind a valid Better Auth session (the @@ -114,7 +126,9 @@ const makeApprovalHandler = }).pipe(Effect.orElseSucceed(() => null)), ); if (!session) return jsonResponse({ error: "Unauthorized" }, 401); - const principal = principalFromSession(session, betterAuth); + const principal = await Effect.runPromise( + principalFromSession(session, betterAuth, request.headers), + ); return ( (await store.handlePausedRequest(request, principal)) ?? diff --git a/apps/host-selfhost/src/mcp/mcp.test.ts b/apps/host-selfhost/src/mcp/mcp.test.ts index 42a9379744..60f07d2988 100644 --- a/apps/host-selfhost/src/mcp/mcp.test.ts +++ b/apps/host-selfhost/src/mcp/mcp.test.ts @@ -18,7 +18,17 @@ afterAll(() => dispose()); const BASE = "http://localhost:4788"; -const signUp = async (email: string): Promise => { +interface AuthenticatedIdentity { + readonly token: string; + readonly cookie: string; +} + +const identityFromResponse = (response: Response): AuthenticatedIdentity => ({ + token: response.headers.get("set-auth-token") ?? "", + cookie: response.headers.get("set-cookie")?.split(";", 1)[0] ?? "", +}); + +const signUpIdentity = async (email: string): Promise => { const inviteCode = await mintInviteCode(handler); const res = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { @@ -28,12 +38,29 @@ const signUp = async (email: string): Promise => { }), ); expect(res.status).toBe(200); - return res.headers.get("set-auth-token") ?? ""; + return identityFromResponse(res); +}; + +const signUp = async (email: string): Promise => (await signUpIdentity(email)).token; + +const signInBootstrap = async (): Promise => { + const response = await handler( + new Request(`${BASE}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, + password: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, + }), + }), + ); + expect(response.status).toBe(200); + return identityFromResponse(response); }; -const mcp = (token: string, body: unknown, sessionId?: string) => +const mcp = (token: string, body: unknown, sessionId?: string, browser = false) => handler( - new Request(`${BASE}/mcp`, { + new Request(`${BASE}/mcp${browser ? "?elicitation_mode=browser" : ""}`, { method: "POST", headers: { authorization: `Bearer ${token}`, @@ -45,26 +72,42 @@ const mcp = (token: string, body: unknown, sessionId?: string) => }), ); -const initSession = async (token: string): Promise => { - const res = await mcp(token, { - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "t", version: "1" }, +const initSession = async (token: string, browser = false): Promise => { + const res = await mcp( + token, + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "t", version: "1" }, + }, }, - }); + undefined, + browser, + ); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("application/json"); const sessionId = res.headers.get("mcp-session-id") ?? ""; expect(sessionId).not.toBe(""); await res.text(); - await mcp(token, { jsonrpc: "2.0", method: "notifications/initialized" }, sessionId); + await mcp(token, { jsonrpc: "2.0", method: "notifications/initialized" }, sessionId, browser); return sessionId; }; +const account = (token: string, path: string, init?: RequestInit) => + handler( + new Request(`${BASE}${path}`, { + ...init, + headers: { + ...Object.fromEntries(new Headers(init?.headers)), + authorization: `Bearer ${token}`, + }, + }), + ); + test("an authenticated MCP client initializes, lists tools, and executes code", async () => { const token = await signUp("alice@mcp.test"); const sessionId = await initSession(token); @@ -173,3 +216,142 @@ test("GET /mcp without a session id is 400; DELETE without a session id is 204", expect(del.status).toBe(204); expect(await del.text()).toBe(""); }); + +test("a browser approval uses the bootstrap admin's demoted membership at the sink", async () => { + const controller = await signInBootstrap(); + const actorEmail = "approval-role-actor@mcp.test"; + const actor = await signUpIdentity(actorEmail); + + const members = async (token: string) => { + const response = await account(token, "/api/account/members"); + expect(response.status).toBe(200); + return (await response.json()) as { + readonly members: ReadonlyArray<{ + readonly id: string; + readonly userId: string; + readonly email: string; + readonly role: string; + }>; + }; + }; + const setRole = (token: string, memberId: string, roleSlug: "admin" | "member") => + account(token, `/api/account/members/${encodeURIComponent(memberId)}/role`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ roleSlug }), + }); + + const actorMember = (await members(controller.token)).members.find( + (member) => member.email === actorEmail, + ); + expect(actorMember?.role).toBe("member"); + if (!actorMember) return; + expect((await setRole(controller.token, actorMember.id, "admin")).status).toBe(200); + const globalAdmin = await account(controller.token, "/api/auth/admin/set-role", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: actorMember.userId, role: "admin" }), + }); + expect(globalAdmin.status).toBe(200); + + const actorSession = await account(actor.token, "/api/auth/get-session"); + expect(actorSession.status).toBe(200); + expect( + ((await actorSession.json()) as { readonly user?: { readonly role?: string } }).user?.role, + ).toBe("admin"); + + const gateResponse = await account(actor.token, "/api/policies", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + owner: "org", + pattern: "executor.coreTools.policies.create", + action: "require_approval", + }), + }); + expect(gateResponse.status).toBe(200); + const gate = (await gateResponse.json()) as { readonly id: string }; + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: async HTTP integration test guarantees role and policy cleanup after every assertion failure + try { + const sessionId = await initSession(actor.token, true); + const pausedResponse = await mcp( + actor.token, + { + jsonrpc: "2.0", + id: 10, + method: "tools/call", + params: { + name: "execute", + arguments: { + code: [ + "return await tools.executor.coreTools.policies.create({", + ' owner: "org",', + ' pattern: "selfhost-live-role-regression.*",', + ' action: "block"', + "});", + ].join("\n"), + }, + }, + }, + sessionId, + true, + ); + const paused = (await pausedResponse.json()) as { + readonly result?: { + readonly structuredContent?: { readonly executionId?: string }; + }; + }; + const executionId = paused.result?.structuredContent?.executionId; + expect(executionId).toBeTruthy(); + if (!executionId) return; + + expect((await setRole(controller.token, actorMember.id, "member")).status).toBe(200); + + const resumePromise = mcp( + actor.token, + { + jsonrpc: "2.0", + id: 11, + method: "tools/call", + params: { name: "resume", arguments: { executionId } }, + }, + sessionId, + true, + ); + await Promise.resolve(); + const decision = await handler( + new Request( + `${BASE}/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent(executionId)}/resume`, + { + method: "POST", + headers: { "content-type": "application/json", cookie: actor.cookie }, + body: JSON.stringify({ action: "accept", content: {} }), + }, + ), + ); + expect(decision.status).toBe(200); + const resumed = await resumePromise; + expect(resumed.status).toBe(200); + await resumed.text(); + + const policiesResponse = await account(controller.token, "/api/policies"); + expect(policiesResponse.status).toBe(200); + const policies = (await policiesResponse.json()) as ReadonlyArray<{ readonly pattern: string }>; + expect(policies.some((policy) => policy.pattern === "selfhost-live-role-regression.*")).toBe( + false, + ); + } finally { + await setRole(controller.token, actorMember.id, "admin"); + await account(actor.token, `/api/policies/${encodeURIComponent(gate.id)}`, { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ owner: "org" }), + }); + await account(controller.token, "/api/auth/admin/set-role", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: actorMember.userId, role: "user" }), + }); + } +}); diff --git a/apps/host-selfhost/src/multi-user.test.ts b/apps/host-selfhost/src/multi-user.test.ts index 6d54393667..c105e4ba31 100644 --- a/apps/host-selfhost/src/multi-user.test.ts +++ b/apps/host-selfhost/src/multi-user.test.ts @@ -42,8 +42,8 @@ const TINY_SPEC = JSON.stringify({ }, }); -const signUp = async (email: string): Promise => { - const inviteCode = await mintInviteCode(handler); +const signUp = async (email: string, role: "admin" | "member" = "member"): Promise => { + const inviteCode = await mintInviteCode(handler, role); const res = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { method: "POST", @@ -136,7 +136,9 @@ const runCode = async (token: string, code: string) => { }; test("multiple accounts share one org but isolate per-user connections", async () => { - const alice = await signUp("alice@multi.test"); + // Workspace-level setup (the catalog, org-shared connections) is admin-only, + // so Alice joins as an admin; Bob stays a plain member. + const alice = await signUp("alice@multi.test", "admin"); const bob = await signUp("bob@multi.test"); // Same single org for both members. @@ -147,6 +149,32 @@ test("multiple accounts share one org but isolate per-user connections", async ( // The integration is tenant-scoped; register it once. expect((await addIntegration(alice, "tiny")).status).toBe(200); + // A plain member cannot register integrations or mint Workspace connections, + // but may still add a Personal credential. + expect((await addIntegration(bob, "tiny2")).status).toBe(403); + expect( + ( + await createConnection(bob, { + owner: "org", + name: "bob-shared", + integration: "tiny", + template: "bearer", + value: "bob-token", + }) + ).status, + ).toBe(403); + expect( + ( + await createConnection(bob, { + owner: "user", + name: "bob-private", + integration: "tiny", + template: "bearer", + value: "bob-token", + }) + ).status, + ).toBe(200); + // Alice attaches a USER-owned connection (private to her) and an ORG-owned // connection (shared across the tenant). expect( @@ -181,13 +209,16 @@ test("multiple accounts share one org but isolate per-user connections", async ( aliceConns.some((a) => a.includes("org") && a.includes(connectionName("team-shared"))), ).toBe(true); - // Bob — a different user in the SAME org — sees the org connection but NOT - // Alice's user-owned one. + // Bob — a different user in the SAME org — sees the org connection and his + // own Personal connection, but NOT Alice's user-owned one. const bobConns = await connectionAddresses(bob); expect(bobConns.some((a) => a.includes("org") && a.includes(connectionName("team-shared")))).toBe( true, ); expect(bobConns.some((a) => a.includes(connectionName("alice-private")))).toBe(false); + expect( + bobConns.some((a) => a.includes("user") && a.includes(connectionName("bob-private"))), + ).toBe(true); }); test("each account can execute code in its own scoped sandbox", async () => { diff --git a/apps/host-selfhost/src/testing/test-app.ts b/apps/host-selfhost/src/testing/test-app.ts index 2261de31c8..23be89ff44 100644 --- a/apps/host-selfhost/src/testing/test-app.ts +++ b/apps/host-selfhost/src/testing/test-app.ts @@ -89,6 +89,8 @@ export const singleAdminIdentityLayer = ( name: "Admin", avatarUrl: null, roles: ["admin"], + orgRoleModel: "organization", + orgRole: "admin", }), }), ); @@ -126,6 +128,8 @@ export const headerIdentityLayer: Layer.Layer = Layer.succeed( name: userId, avatarUrl: null, roles: ["admin"], + orgRoleModel: "organization", + orgRole: "admin", }); }, }), diff --git a/apps/local/drizzle/0006_bored_landau.sql b/apps/local/drizzle/0006_bored_landau.sql new file mode 100644 index 0000000000..050ecbf687 --- /dev/null +++ b/apps/local/drizzle/0006_bored_landau.sql @@ -0,0 +1,2 @@ +ALTER TABLE `connection` ADD `credential_write` text;--> statement-breakpoint +ALTER TABLE `oauth_client` ADD `credential_write` text; \ No newline at end of file diff --git a/apps/local/drizzle/meta/0006_snapshot.json b/apps/local/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000000..4ebf2ff210 --- /dev/null +++ b/apps/local/drizzle/meta/0006_snapshot.json @@ -0,0 +1,948 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "89a4fd1b-f0f6-4482-a991-0db78c859f76", + "prevId": "e917e9d4-f3b5-453d-8549-540e2ecc986f", + "tables": { + "blob": { + "name": "blob", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": ["id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connection": { + "name": "connection", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_ids": { + "name": "item_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_write": { + "name": "credential_write", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_state": { + "name": "provider_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": ["tenant", "owner", "subject", "integration", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "definition": { + "name": "definition", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "can_remove": { + "name": "can_remove", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": ["tenant", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_write": { + "name": "credential_write", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": ["tenant", "owner", "subject", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_session": { + "name": "oauth_session", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": ["tenant", "state"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": ["tenant", "owner", "subject", "plugin_id", "collection", "key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool": { + "name": "tool", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_schema": { + "name": "input_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_schema": { + "name": "output_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_policy": { + "name": "tool_policy", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": ["tenant", "owner", "subject", "id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index 0417be2f2a..dbe786204c 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1788164746371, "tag": "0005_oauth_client_token_auth", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1788255902609, + "tag": "0006_bored_landau", + "breakpoints": true } ] } diff --git a/apps/local/src/db/executor-schema.ts b/apps/local/src/db/executor-schema.ts index ae15c47426..a7f6ceb48e 100644 --- a/apps/local/src/db/executor-schema.ts +++ b/apps/local/src/db/executor-schema.ts @@ -37,6 +37,7 @@ export const connection = sqliteTable( template: text("template").notNull(), provider: text("provider").notNull(), item_ids: text("item_ids").notNull(), + credential_write: text("credential_write"), identity_label: text("identity_label"), oauth_client: text("oauth_client"), oauth_client_owner: text("oauth_client_owner"), @@ -72,6 +73,7 @@ export const oauth_client = sqliteTable( grant: text("grant").notNull(), client_id: text("client_id").notNull(), client_secret_item_id: text("client_secret_item_id"), + credential_write: text("credential_write"), token_endpoint_auth_method: text("token_endpoint_auth_method"), resource: text("resource"), origin_kind: text("origin_kind"), diff --git a/apps/local/src/identity.ts b/apps/local/src/identity.ts index 213f53c3dc..27a7e901c9 100644 --- a/apps/local/src/identity.ts +++ b/apps/local/src/identity.ts @@ -39,6 +39,7 @@ export const LOCAL_PRINCIPAL: Principal = { name: "Local", avatarUrl: null, roles: [], + orgRoleModel: "none", }; const bearerToken = (headers: Headers): string | undefined => { diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index 2de3221985..caa5548d4a 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -287,7 +287,12 @@ export const createMcpRequestHandler = ( const response = await readResumeResponse(request); if (!response) return json({ error: "Invalid approval response" }, 400); - await Effect.runPromise(approvals.recordResponse(executionId, response)); + await Effect.runPromise( + approvals.recordResponse(executionId, { + response, + orgWriteAccess: "allowed", + }), + ); return json(resumeApprovalResult(executionId, response)); }, diff --git a/e2e/cloud/admin-users-console.test.ts b/e2e/cloud/admin-users-console.test.ts index d5eaa0521f..4882e35985 100644 --- a/e2e/cloud/admin-users-console.test.ts +++ b/e2e/cloud/admin-users-console.test.ts @@ -9,9 +9,9 @@ // access rather than shown an empty workspace. // // Two members are built through the REAL flows (login → create-organization → -// invite → accept-invitation, in `./support/session`) and each connects their -// own credential, so the two rows differ in what they've connected and the -// summary has something to be right about. +// invite → accept-invitation, in `./support/session`). Each connects a Personal +// credential; the plain member's Workspace attempt is refused, so the directory +// and connection UI prove the owner-aware permission boundary together. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; @@ -39,11 +39,12 @@ declare global { } const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); +const INTEGRATION_TITLE = "Ping API"; /** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ const pingSpec = JSON.stringify({ openapi: "3.0.3", - info: { title: "Ping API", version: "1.0.0" }, + info: { title: INTEGRATION_TITLE, version: "1.0.0" }, paths: { "/ping": { get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, @@ -51,13 +52,19 @@ const pingSpec = JSON.stringify({ }, }); +const noProbeSpec = JSON.stringify({ + openapi: "3.0.3", + info: { title: INTEGRATION_TITLE, version: "1.0.0" }, + paths: {}, +}); + /** Registers a fresh apiKey-authenticated integration for connections to bind to. */ -const registerIntegration = (client: Client, label: string) => +const registerIntegration = (client: Client, label: string, spec = pingSpec) => Effect.gen(function* () { const slug = IntegrationSlug.make(`${label}-${randomBytes(4).toString("hex")}`); yield* client.openapi.addSpec({ payload: { - spec: { kind: "blob", value: pingSpec }, + spec: { kind: "blob", value: spec }, slug, baseUrl: "http://127.0.0.1:59999", // never contacted during registration authenticationTemplate: [ @@ -91,18 +98,19 @@ scenario( const adminId = yield* accountIdOf(target, admin); const memberId = yield* accountIdOf(target, member); - // Two integrations so the summary has a real available-vs-connected split: - // each member connects one, so each row shows one connected and one not. + // Two integrations so the member's summary has a real zero-of-two state. const connectedIntegration = yield* registerIntegration(adminClient, "admin-ui-conn"); - const availableIntegration = yield* registerIntegration(adminClient, "admin-ui-avail"); + const availableIntegration = yield* registerIntegration( + adminClient, + "admin-ui-avail", + noProbeSpec, + ); const adminConnection = freshConnectionName(); const memberConnection = freshConnectionName(); - yield* Effect.ensuring( Effect.gen(function* () { - // Each member stores their OWN credential. Neither can see the other's - // through the product plane — the admin page is the only surface that - // reports both. + // Both roles may store Personal credentials. A member cannot promote + // theirs into a Workspace credential by bypassing the owner picker. yield* adminClient.connections.create({ payload: { owner: "user", @@ -112,6 +120,18 @@ scenario( value: "admin-personal-token", }, }); + const workspaceRefusal = yield* memberClient.connections + .create({ + payload: { + owner: "org", + name: memberConnection, + integration: connectedIntegration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }) + .pipe(Effect.flip); + expect(workspaceRefusal).toMatchObject({ _tag: "OrgWriteDeniedError" }); yield* memberClient.connections.create({ payload: { owner: "user", @@ -189,7 +209,7 @@ scenario( await summary.waitFor({ state: "visible", timeout: 30_000 }); expect( await summary.textContent(), - "one of the two connectable integrations, with the built-in out of both numbers", + "one connectable integration is connected, with the built-in out of both numbers", ).toBe("1/2"); expect( await memberRow.locator("[data-integration='executor']").count(), @@ -199,7 +219,7 @@ scenario( await memberRow .locator(`[data-integration='${connectedIntegration}'][data-connected='true']`) .count(), - "the integration this member connected is lit in their summary", + "the member's Personal credential is lit in their summary", ).toBe(1); expect( await memberRow @@ -209,7 +229,7 @@ scenario( ).toBe(1); }); - await step("Open the member's detail and read their connections", async () => { + await step("Open the member's detail and confirm their Personal connection", async () => { await page .locator("[data-slot='admin-user-row']") .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) }) @@ -217,11 +237,9 @@ scenario( const detail = page.getByRole("dialog"); await detail.waitFor({ state: "visible", timeout: 30_000 }); - // Their own connection, by name, with the shared health vocabulary. await detail .getByText(memberConnection, { exact: true }) .waitFor({ state: "visible", timeout: 30_000 }); - // Never probed, so the honest verdict is Unchecked — not Healthy. expect( await detail.getByLabel("Status: Unchecked").count(), "a never-probed connection reads as unchecked, not healthy", @@ -285,11 +303,12 @@ scenario( .count(), "the org-free form is never rendered on a host that has orgs", ).toBe(0); - // Exactly one: the member connected one of the two connectable - // integrations, and the built-in offers no link at all. + // Only the unconnected integration is available; the member's + // Personal connection consumes the other slot. The built-in still + // offers no link at all. expect( await detail.getByRole("button", { name: "Copy link" }).count(), - "one link per not-connected connectable integration, and none for the built-in", + "one link for the not-connected integration, and none for the built-in", ).toBe(1); expect( await detail.getByText("/connect/executor", { exact: false }).count(), @@ -316,6 +335,25 @@ scenario( new URL(page.url()).searchParams.get("addAccount"), "the link lands in the connect flow, not just on the page", ).toBe("1"); + const dialog = page.getByRole("dialog"); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + const credential = dialog.getByRole("textbox", { name: "authorization" }); + await credential.waitFor({ state: "visible", timeout: 90_000 }); + await credential.fill("admin-scope-proof-token"); + await dialog.getByRole("button", { name: "Continue" }).click(); + const owner = dialog.getByRole("combobox"); + await owner.waitFor({ state: "visible", timeout: 30_000 }); + expect(await owner.textContent(), "an admin is offered Personal scope").toContain( + "Personal", + ); + await owner.click(); + await page + .getByRole("option", { name: "Workspace", exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); }); }); @@ -362,6 +400,38 @@ scenario( ).toBe(0); }, ); + + await step( + "The member can add Personal connections without a scope dropdown", + async () => { + await visit(page, `/${slug}/integrations/${availableIntegration}?tab=accounts`); + const add = page.getByRole("button", { name: "Add connection" }); + await add.waitFor({ state: "visible", timeout: 30_000 }); + await add.click(); + const dialog = page.getByRole("dialog"); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await dialog.getByText("Workspace", { exact: true }).count(), + "the member is forced to Personal rather than offered a scope picker", + ).toBe(0); + await page.keyboard.press("Escape"); + }, + ); + + await step("Their connect deep link opens the Personal add flow", async () => { + await visit(page, `/${slug}/connect/${availableIntegration}`); + await page.waitForURL( + (url) => url.pathname === `/${slug}/integrations/${availableIntegration}`, + { timeout: 30_000 }, + ); + expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1"); + await page + .getByRole("dialog") + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + }); }); }), Effect.all( diff --git a/e2e/cloud/admin-users.test.ts b/e2e/cloud/admin-users.test.ts index 70bf366b96..2be5a29301 100644 --- a/e2e/cloud/admin-users.test.ts +++ b/e2e/cloud/admin-users.test.ts @@ -4,8 +4,8 @@ // member of the tenant instead of binding to one. // // Two members are built through the REAL flows (login → create-organization → -// invite → accept-invitation), each connects their own credential, and the -// admin then reads the joined view — the exact shape a customer dashboard's +// invite → accept-invitation), each connects their own Personal credential, and +// the admin then reads the joined view — the exact shape a customer dashboard's // icon grid consumes. The guarantees pinned here: // // 1. the joined view reports BOTH members and each one's own connections, @@ -96,8 +96,8 @@ scenario( yield* Effect.ensuring( Effect.gen(function* () { - // Each member stores their OWN credential. Neither can see the other's - // through the product plane — that is the whole point of the admin one. + // Each member stores their OWN Personal credential. Neither can see the + // other's through the product plane — that is the admin view's job. yield* adminClient.connections.create({ payload: { owner: "user", @@ -116,7 +116,6 @@ scenario( value: "member-personal-token", }, }); - const client = yield* apiClient(AdminUsersHttpApi, admin); // (1) The joined view: both members, each with their own connection. diff --git a/e2e/cloud/connect-link-multi-org.test.ts b/e2e/cloud/connect-link-multi-org.test.ts index b268aec786..6d219142d9 100644 --- a/e2e/cloud/connect-link-multi-org.test.ts +++ b/e2e/cloud/connect-link-multi-org.test.ts @@ -16,8 +16,9 @@ // same place. `packages/react/src/routes/connect-deep-link.test.ts` only proves // the router parses the param. Neither has a second org to land in by mistake. // -// So: a recipient who is a member of TWO orgs, whose session defaults to org B, -// follows an org-A-scoped link, and the credential must end up in org A. +// So: a recipient who is a plain member of TWO orgs, whose session defaults to +// org B, follows an org-A-scoped link. The request must open the forced-Personal +// connection flow in org A — never enter a connection flow in B. // // Both orgs register an integration under the SAME slug — that is what makes // this a real test. With distinct slugs, org B's catalog would simply not @@ -41,8 +42,6 @@ import { activeOrg, forBrowser, joinOrg, organizationsOf } from "./support/sessi const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; -/** The spec's title, which the console uses to name a saved connection - * ("Personal Ping API"). */ const INTEGRATION_TITLE = "Ping API"; /** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ @@ -75,7 +74,7 @@ const registerIntegration = (client: Client, slug: IntegrationSlug) => }); scenario( - "Connect · an org-scoped connect link lands a multi-org recipient in the SENDING org", + "Connect · an org-scoped connect link resolves a multi-org recipient in the SENDING org", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; @@ -136,54 +135,44 @@ scenario( // connections open, so idle is not a state this page reaches. The // real wait is the redirect assertion below. await page.goto(connectLink, { waitUntil: "domcontentloaded" }); - // The deep link forwards into the integration detail route with the - // add-account handoff — and it must keep ORG A's prefix through the - // redirect. Landing on `/${orgB.slug}/...` here IS the bug. + // A plain member can add a Personal connection. The org-A prefix + // still proves the link resolved against the SENDING workspace + // rather than session org B. await page.waitForURL((url) => url.pathname === `/${orgA.slug}/integrations/${slug}`, { timeout: 30_000, }); expect( new URL(page.url()).pathname.split("/").filter(Boolean)[0], - "the connect flow stayed in the SENDING org, not the session default", + "the Personal add flow is scoped to the SENDING org, not the session default", ).toBe(orgA.slug); - expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1"); + expect( + new URL(page.url()).searchParams.get("addAccount"), + "the member enters the add-account route", + ).toBe("1"); + const dialog = page.getByRole("dialog"); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await dialog.getByText("Workspace", { exact: true }).count(), + "the member is forced to Personal scope", + ).toBe(0); }); - await step("Complete the connection from that page", async () => { + await step("Complete the Personal connection in org A", async () => { const dialog = page.getByRole("dialog"); - await dialog.getByRole("heading", { name: /Add connection/ }).waitFor({ - timeout: 30_000, - }); - // The credential field is labelled by the method's PLACEMENT (the - // `authorization` header this spec declares), not by the variable. - // Waited for explicitly: the field renders only once the modal has - // loaded the integration's auth methods, which is a second fetch - // after the heading appears. const credential = dialog.getByRole("textbox", { name: "authorization" }); await credential.waitFor({ state: "visible", timeout: 90_000 }); await credential.fill("recipient-personal-token"); - // The offered health check is opt-in (it runs only on "Check"), and - // this spec's base URL is never served — so the credential is saved - // unprobed, which is what this scenario is about: WHERE it lands, - // not whether it works. await dialog.getByRole("button", { name: "Continue" }).click(); await dialog.getByRole("button", { name: "Add connection" }).click(); - // The saved credential appears as a row in the accounts list of the - // page it was saved from — and that page is ORG A's (its URL was - // pinned to `orgA.slug` in the step above). So this row IS the - // "landed in the sending workspace" half of the guarantee, read - // from the surface the recipient is actually looking at. - // - // Waited on rather than the success toast (which auto-dismisses - // below the fold) or the dialog's disappearance (which races the - // close animation). await page .getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }) .first() .waitFor({ state: "visible", timeout: 90_000 }); expect( new URL(page.url()).pathname.split("/").filter(Boolean)[0], - "the credential was saved from a page scoped to the SENDING org", + "the submitted Personal connection stayed in the sending org", ).toBe(orgA.slug); }); @@ -191,35 +180,29 @@ scenario( // // Same person, same session, same integration slug — the only thing // that differs is the org in the URL. Org B registered the SAME slug, - // so this page exists and renders; it simply must hold no connection. - // Had the link resolved against the session default, THIS is the page - // the credential would be on. - await step("Org B — the session's own default — has none", async () => { + // so this page exists and must resolve an empty connection list. + await step("Org B has no persisted connection", async () => { await page.goto(`${origin}/${orgB.slug}/integrations/${slug}?tab=accounts`, { waitUntil: "domcontentloaded", }); - // Wait for the accounts panel to finish loading before asserting an - // absence, so "not rendered yet" cannot pass as "not there". The - // empty state is the positive signal that the list resolved AND is - // empty — checking only for the missing row would also pass while - // the list was still loading. - await page - .getByRole("button", { name: "Add connection" }) - .first() - .waitFor({ state: "visible", timeout: 90_000 }); + const add = page.getByRole("button", { name: "Add connection" }); + await add.waitFor({ state: "visible", timeout: 90_000 }); await page .getByText("No connections", { exact: false }) .first() .waitFor({ state: "visible", timeout: 90_000 }); + expect( + new URL(page.url()).pathname.split("/").filter(Boolean)[0], + "navigating explicitly to org B changes the active add-flow scope", + ).toBe(orgB.slug); expect( await page.getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }).count(), - "nothing landed in the org the recipient's session happened to default to", + "the submitted connection did not land in the session-default org", ).toBe(0); }); }); }), - // Removing each org's spec takes its connections with it, so the - // UI-created credential (whose name the console chose) needs no lookup. + // Remove the same-slug fixtures from both organizations. Effect.all( [ ownerAClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), diff --git a/e2e/cloud/credential-write-durability.test.ts b/e2e/cloud/credential-write-durability.test.ts index 3353513cdc..913ce4ac9c 100644 --- a/e2e/cloud/credential-write-durability.test.ts +++ b/e2e/cloud/credential-write-durability.test.ts @@ -525,10 +525,16 @@ scenario( const objects = yield* vaultObjectsFor(workos, slug); const refreshObject = objects.find((object) => object.name.endsWith("refresh")); expect(refreshObject, "the connection stored a refresh token in the vault").toBeDefined(); - const accessObject = objects.find( - (object) => object !== refreshObject && refreshObject!.name.startsWith(object.name), - ); + const accessObjects = objects.filter((object) => object.id !== refreshObject?.id); + expect( + accessObjects, + "the connection stored exactly one access token in the vault", + ).toHaveLength(1); + const accessObject = accessObjects[0]; expect(accessObject, "the connection stored an access token in the vault").toBeDefined(); + expect(accessObject!.id, "access and refresh are distinct vault objects").not.toBe( + refreshObject!.id, + ); const interrupted = yield* Effect.scoped( Effect.gen(function* () { diff --git a/e2e/cloud/integrations-api.test.ts b/e2e/cloud/integrations-api.test.ts index 785fe3d09b..2b3c3ded7b 100644 --- a/e2e/cloud/integrations-api.test.ts +++ b/e2e/cloud/integrations-api.test.ts @@ -271,7 +271,7 @@ scenario( name: MAIN, integration: slug, template: NONE, - value: "unused", + value: "", }, }); @@ -328,7 +328,7 @@ scenario( name: MAIN, integration: slug, template: NONE, - value: "unused", + value: "", }, }); diff --git a/e2e/cloud/integrations-refresh.test.ts b/e2e/cloud/integrations-refresh.test.ts index fe2b70afd8..d4c4b5d46e 100644 --- a/e2e/cloud/integrations-refresh.test.ts +++ b/e2e/cloud/integrations-refresh.test.ts @@ -103,7 +103,7 @@ scenario( name: MAIN, integration: slug, template: NONE, - value: "unused", + value: "", }, }); diff --git a/e2e/cloud/mcp-workspace-write-permissions.test.ts b/e2e/cloud/mcp-workspace-write-permissions.test.ts new file mode 100644 index 0000000000..7c13c8c16e --- /dev/null +++ b/e2e/cloud/mcp-workspace-write-permissions.test.ts @@ -0,0 +1,82 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; +import { joinOrg } from "./support/session"; + +const api = composePluginApi([] as const); + +const createPolicyCode = (pattern: string): string => ` +const created = await tools.executor.coreTools.policies.create({ + owner: "org", + pattern: ${JSON.stringify(pattern)}, + action: "approve" +}); +return JSON.stringify(created); +`; + +scenario( + "MCP workspace writes · a member session is denied while an admin session succeeds", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + const adminClient = yield* makeClient(api, admin); + const pattern = `executor.mcp-role-${randomBytes(4).toString("hex")}.*`; + + const cleanup = adminClient.policies.list().pipe( + Effect.flatMap((policies) => + Effect.forEach( + policies.filter((policy) => policy.pattern === pattern), + (policy) => + adminClient.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore({ log: false })), + { discard: true }, + ), + ), + Effect.ignore({ log: false }), + ); + + yield* Effect.ensuring( + Effect.gen(function* () { + const memberSession = mcp.session(member); + let denied = yield* memberSession.call("execute", { code: createPolicyCode(pattern) }); + if (denied.text.includes("Execution paused")) { + denied = yield* memberSession.approvePaused(denied.text); + } + expect( + denied.text, + "the denial preserves the workspace-admin authorization failure", + ).toMatch(/OrgWriteDenied|administrator|admin/i); + expect( + (yield* adminClient.policies.list()).some((policy) => policy.pattern === pattern), + "the member's denied MCP call persisted no Workspace policy", + ).toBe(false); + + const adminSession = mcp.session(admin); + let allowed = yield* adminSession.call("execute", { code: createPolicyCode(pattern) }); + if (allowed.text.includes("Execution paused")) { + allowed = yield* adminSession.approvePaused(allowed.text); + } + expect(allowed.ok, "the admin's MCP workspace-write call succeeds").toBe(true); + expect(allowed.text, "the created policy is returned over the MCP session").toContain( + pattern, + ); + expect( + (yield* adminClient.policies.list()).some((policy) => policy.pattern === pattern), + "the admin's MCP call persisted the Workspace policy", + ).toBe(true); + }), + cleanup, + ); + }), +); diff --git a/e2e/cloud/workspace-write-permissions.test.ts b/e2e/cloud/workspace-write-permissions.test.ts new file mode 100644 index 0000000000..955e175f3a --- /dev/null +++ b/e2e/cloud/workspace-write-permissions.test.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { workspaceWritePermissions } from "../src/workspace-write-permissions"; +import { joinOrg } from "./support/session"; + +scenario( + "Workspace writes · cloud members are denied while Personal writes and admin writes succeed", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + yield* workspaceWritePermissions(target, admin, member); + }), +); diff --git a/e2e/scenarios/graphql-introspection-health.test.ts b/e2e/scenarios/graphql-introspection-health.test.ts index eb7cce6fe6..b904f9c75f 100644 --- a/e2e/scenarios/graphql-introspection-health.test.ts +++ b/e2e/scenarios/graphql-introspection-health.test.ts @@ -178,7 +178,7 @@ scenario( name: ConnectionName.make("workspace"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("none"), - value: "unused", + value: "", }, }); diff --git a/e2e/selfhost/admin-users-console.test.ts b/e2e/selfhost/admin-users-console.test.ts index fccdef9251..906e699429 100644 --- a/e2e/selfhost/admin-users-console.test.ts +++ b/e2e/selfhost/admin-users-console.test.ts @@ -34,11 +34,12 @@ declare global { } const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); +const INTEGRATION_TITLE = "Ping API"; /** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ const pingSpec = JSON.stringify({ openapi: "3.0.3", - info: { title: "Ping API", version: "1.0.0" }, + info: { title: INTEGRATION_TITLE, version: "1.0.0" }, paths: { "/ping": { get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, @@ -91,11 +92,22 @@ scenario( // guaranteed to render at least one connect link to assert the shape of. const availableIntegration = yield* registerIntegration(ownerClient, "admin-ui-sh-avail"); const memberConnection = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); - yield* Effect.ensuring( Effect.gen(function* () { - // The member stores their own credential, so the owner's view has - // something to report that the owner's product view cannot see. + // Members may add Personal credentials, but the API refuses the same + // request in Workspace scope even if they bypass the owner picker. + const refusal = yield* memberClient.connections + .create({ + payload: { + owner: "org", + name: memberConnection, + integration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }) + .pipe(Effect.flip); + expect(refusal).toMatchObject({ _tag: "OrgWriteDeniedError" }); yield* memberClient.connections.create({ payload: { owner: "user", @@ -119,7 +131,7 @@ scenario( .waitFor({ state: "visible", timeout: 30_000 }); }); - await step("The invited member's connection is attributed to them", async () => { + await step("The invited member is listed with their Personal connection", async () => { // Selfhost shares one org across scenarios, so this asserts the // member's own row exists — never a count of the whole instance. const row = page @@ -312,6 +324,38 @@ scenario( "the refusal replaces the table rather than rendering it empty", ).toBe(0); }); + + await step("A member can add Personal connections without a scope dropdown", async () => { + await visit(page, `/integrations/${availableIntegration}?tab=accounts`); + const add = page.getByRole("button", { name: "Add connection" }); + await add.waitFor({ state: "visible", timeout: 30_000 }); + await add.click(); + const dialog = page.getByRole("dialog"); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await dialog.getByText("Workspace", { exact: true }).count(), + "the member is forced to Personal rather than offered a scope picker", + ).toBe(0); + await page.keyboard.press("Escape"); + }); + + await step("A member's connect deep link opens the Personal add flow", async () => { + await visit(page, `/connect/${availableIntegration}`); + await page.waitForURL( + (url) => url.pathname.endsWith(`/integrations/${availableIntegration}`), + { timeout: 30_000 }, + ); + expect( + new URL(page.url()).searchParams.get("addAccount"), + "the deep link enters the member's forced-Personal add flow", + ).toBe("1"); + await page + .getByRole("dialog") + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + }); }); }), Effect.all( diff --git a/e2e/selfhost/mcp-browser-approval-live-role.test.ts b/e2e/selfhost/mcp-browser-approval-live-role.test.ts new file mode 100644 index 0000000000..a8fea1cf77 --- /dev/null +++ b/e2e/selfhost/mcp-browser-approval-live-role.test.ts @@ -0,0 +1,174 @@ +// Selfhost-only: browser approval must authorize with the actor's current +// organization membership, not Better Auth's global user role. A user invited +// as an admin keeps `user.role = "admin"` after membership demotion, so this +// scenario pauses while privileged, demotes the membership, and proves the +// resumed workspace mutation is denied at the sink. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; +import { parseBrowserApproval } from "../src/surfaces/mcp"; +import { createInvitedIdentity } from "../targets/selfhost"; + +const coreApi = composePluginApi([] as const); +const GATE_TOOL = "executor.coreTools.policies.create"; +const CREATED_PATTERN = "selfhost-live-role-regression.*"; +const EXECUTE_CODE = ` +const result = await tools.executor.coreTools.policies.create({ + owner: "org", + pattern: ${JSON.stringify(CREATED_PATTERN)}, + action: "block", +}); +return JSON.stringify(result); +`; + +interface MemberRow { + readonly id: string; + readonly userId: string; + readonly email: string; + readonly role: string; +} + +const accountRequest = ( + baseUrl: string, + cookie: string, + path: string, + init?: RequestInit, +): Promise => + fetch(new URL(path, baseUrl), { + ...init, + headers: { + ...Object.fromEntries(new Headers(init?.headers)), + cookie, + origin: new URL(baseUrl).origin, + }, + }); + +scenario( + "MCP browser approval · a demoted self-host admin cannot approve a workspace mutation", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const api = yield* Api; + const mcp = yield* Mcp; + const controller = yield* target.newIdentity(); + const actor = yield* Effect.promise(() => + createInvitedIdentity(target.baseUrl, controller, { + role: "admin", + emailPrefix: "approval-role-actor", + }), + ); + const controllerCookie = controller.headers?.cookie; + const actorCookie = actor.headers?.cookie; + if (typeof controllerCookie !== "string" || typeof actorCookie !== "string") { + return yield* Effect.die("self-host identities did not carry session cookies"); + } + const actorClient = yield* api.client(coreApi, actor); + const gate = yield* actorClient.policies.create({ + payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, + }); + + const membersResponse = yield* Effect.promise(() => + accountRequest(target.baseUrl, controllerCookie, "/api/account/members"), + ); + expect(membersResponse.status, "the controller can list memberships").toBe(200); + const membersBody = yield* Effect.promise( + () => + membersResponse.json() as Promise<{ + readonly members: readonly MemberRow[]; + }>, + ); + const actorMember = membersBody.members.find((member) => member.email === actor.label); + expect( + actorMember?.role === "admin", + "the actor begins with privileged organization membership", + ).toBe(true); + if (!actorMember) return yield* Effect.die("the actor membership was not listed"); + + const setGlobalRole = (role: "admin" | "user"): Effect.Effect => + Effect.promise(() => + accountRequest(target.baseUrl, controllerCookie, "/api/auth/admin/set-role", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: actorMember.userId, role }), + }), + ); + const promoted = yield* setGlobalRole("admin"); + expect(promoted.status, "the actor has a stale global admin role to distrust").toBe(200); + + const setRole = (roleSlug: "admin" | "member"): Effect.Effect => + Effect.promise(() => + accountRequest( + target.baseUrl, + controllerCookie, + `/api/account/members/${encodeURIComponent(actorMember.id)}/role`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ roleSlug }), + }, + ), + ); + + yield* Effect.gen(function* () { + const session = mcp.session(actor, { elicitationMode: "browser" }); + yield* session.listTools(); + const paused = yield* session.call("execute", { code: EXECUTE_CODE }); + const approval = parseBrowserApproval(paused); + const approvalUrl = new URL(approval.approvalUrl); + const sessionId = approvalUrl.searchParams.get("mcp_session_id"); + if (sessionId === null) return yield* Effect.die("approval URL carried no MCP session id"); + + const demoted = yield* setRole("member"); + expect(demoted.status, "the membership demotion commits before approval").toBe(200); + + const [resumed, decision] = yield* Effect.all( + [ + session.awaitResume(approval.executionId), + Effect.promise(() => + accountRequest( + target.baseUrl, + actorCookie, + `/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent( + approval.executionId, + )}/resume`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "accept", content: {} }), + }, + ), + ), + ], + { concurrency: "unbounded" }, + ); + + expect(decision.status, "the browser decision reaches the paused execution").toBe(200); + expect(resumed.ok, "the resumed sandbox reports the tool result normally").toBe(true); + expect( + resumed.text, + "the resumed workspace mutation preserves the workspace-admin denial", + ).toMatch(/OrgWriteDenied|administrator|admin/i); + const policies = yield* actorClient.policies.list(); + expect( + policies.some((policy) => policy.pattern === CREATED_PATTERN), + "the demoted actor did not create the workspace policy", + ).toBe(false); + }).pipe( + Effect.ensuring( + setRole("admin").pipe( + Effect.andThen( + actorClient.policies.remove({ + params: { policyId: gate.id }, + payload: { owner: "org" }, + }), + ), + Effect.andThen(setGlobalRole("user")), + Effect.ignore, + ), + ), + ); + }), +); diff --git a/e2e/selfhost/workspace-write-permissions.test.ts b/e2e/selfhost/workspace-write-permissions.test.ts new file mode 100644 index 0000000000..618a95b56a --- /dev/null +++ b/e2e/selfhost/workspace-write-permissions.test.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect"; + +import { createInvitedIdentity } from "../targets/selfhost"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { workspaceWritePermissions } from "../src/workspace-write-permissions"; + +scenario( + "Workspace writes · self-host members are denied while Personal writes and admin writes succeed", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const member = yield* Effect.promise(() => + createInvitedIdentity(target.baseUrl, admin, { + role: "member", + emailPrefix: "write-permissions-member", + }), + ); + yield* workspaceWritePermissions(target, admin, member); + }), +); diff --git a/e2e/src/workspace-write-permissions.ts b/e2e/src/workspace-write-permissions.ts new file mode 100644 index 0000000000..f4b4a62b3d --- /dev/null +++ b/e2e/src/workspace-write-permissions.ts @@ -0,0 +1,469 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; + +import type { Identity, Target as TargetShape } from "./target"; +import { Api } from "./services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const unique = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; + +const specPayload = (slug: IntegrationSlug) => ({ + spec: { + kind: "blob" as const, + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: `Permissions ${slug}`, version: "1.0.0" }, + paths: { + "/ping": { + get: { + operationId: "ping", + tags: ["ping"], + responses: { "200": { description: "pong" } }, + }, + }, + }, + }), + }, + slug, + baseUrl: "http://127.0.0.1:59999", + authenticationTemplate: [ + { + slug: TEMPLATE, + type: "apiKey" as const, + headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] }, + }, + ], +}); + +const request = ( + target: TargetShape, + identity: Identity, + method: string, + path: string, + body?: unknown, +): Effect.Effect => + Effect.promise(() => + fetch(new URL(path, target.baseUrl), { + method, + headers: { + ...(identity.headers ?? {}), + origin: new URL(target.baseUrl).origin, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + ); + +const expectForbidden = ( + target: TargetShape, + member: Identity, + label: string, + method: string, + path: string, + body?: unknown, +): Effect.Effect => + request(target, member, method, path, body).pipe( + Effect.map((response) => { + expect(response.status, `${label} is denied at the HTTP boundary`).toBe(403); + }), + ); + +/** Exercise the complete workspace-write authorization matrix for one host boot. */ +export const workspaceWritePermissions = (target: TargetShape, admin: Identity, member: Identity) => + Effect.scoped( + Effect.gen(function* () { + const { client: makeClient } = yield* Api; + const oauth = yield* serveOAuthTestServer(); + const adminClient = yield* makeClient(api, admin); + const memberClient = yield* makeClient(api, member); + const prefix = unique("write-matrix"); + + const seedIntegration = IntegrationSlug.make(`${prefix}-seed`); + const deniedIntegration = IntegrationSlug.make(`${prefix}-denied`); + const adminIntegration = IntegrationSlug.make(`${prefix}-admin`); + const connectionSuffix = randomBytes(4).toString("hex"); + const seedConnection = ConnectionName.make(`seed${connectionSuffix}`); + const deniedConnection = ConnectionName.make(`denied${connectionSuffix}`); + const personalConnection = ConnectionName.make(`personal${connectionSuffix}`); + const adminConnection = ConnectionName.make(`admin${connectionSuffix}`); + const seedClient = OAuthClientSlug.make(`${prefix}-seed`); + const deniedManualClient = OAuthClientSlug.make(`${prefix}-denied-manual`); + const deniedDcrClient = OAuthClientSlug.make(`${prefix}-denied-dcr`); + const adminManualClient = OAuthClientSlug.make(`${prefix}-admin-manual`); + const adminDcrClient = OAuthClientSlug.make(`${prefix}-admin-dcr`); + const policyPrefix = `executor.${prefix}`; + let registeredDynamicClient: OAuthClientSlug | undefined; + + yield* adminClient.openapi.addSpec({ payload: specPayload(seedIntegration) }); + yield* adminClient.connections.create({ + payload: { + owner: "org", + name: seedConnection, + integration: seedIntegration, + template: TEMPLATE, + value: `${prefix}-seed-token`, + }, + }); + yield* adminClient.oauth.createClient({ + payload: { + owner: "org", + slug: seedClient, + grant: "authorization_code", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + clientId: `${prefix}-seed-id`, + clientSecret: `${prefix}-seed-secret`, + }, + }); + const seedPolicy = yield* adminClient.policies.create({ + payload: { owner: "org", pattern: `${policyPrefix}.seed`, action: "approve" }, + }); + const seedIntegrationBefore = yield* adminClient.integrations.get({ + params: { slug: seedIntegration }, + }); + + const cleanup = Effect.gen(function* () { + const policies = yield* adminClient.policies.list(); + yield* Effect.forEach( + policies.filter((policy) => policy.pattern.startsWith(policyPrefix)), + (policy) => + adminClient.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore({ log: false })), + { discard: true }, + ); + const clients = yield* adminClient.oauth.listClients(); + yield* Effect.forEach( + clients.filter((client) => String(client.slug).startsWith(prefix)), + (client) => + adminClient.oauth + .removeClient({ params: { slug: client.slug }, payload: { owner: client.owner } }) + .pipe(Effect.ignore({ log: false })), + { discard: true }, + ); + if (registeredDynamicClient !== undefined) { + yield* adminClient.oauth + .removeClient({ + params: { slug: registeredDynamicClient }, + payload: { owner: "org" }, + }) + .pipe(Effect.ignore({ log: false })); + } + for (const name of [seedConnection, adminConnection]) { + yield* adminClient.connections + .remove({ params: { owner: "org", integration: seedIntegration, name } }) + .pipe(Effect.ignore({ log: false })); + } + yield* memberClient.connections + .remove({ + params: { owner: "user", integration: seedIntegration, name: personalConnection }, + }) + .pipe(Effect.ignore({ log: false })); + for (const slug of [seedIntegration, deniedIntegration, adminIntegration]) { + yield* adminClient.openapi + .removeSpec({ params: { slug } }) + .pipe(Effect.ignore({ log: false })); + } + }).pipe(Effect.ignore({ log: false })); + + yield* Effect.ensuring( + Effect.gen(function* () { + const connectionPath = `/api/connections/org/${seedIntegration}/${seedConnection}`; + yield* expectForbidden( + target, + member, + "member org connection create", + "POST", + "/api/connections", + { + owner: "org", + name: deniedConnection, + integration: seedIntegration, + template: TEMPLATE, + value: `${prefix}-denied-token`, + }, + ); + yield* expectForbidden( + target, + member, + "member org connection update", + "PATCH", + connectionPath, + { + description: "denied", + }, + ); + yield* expectForbidden( + target, + member, + "member org connection remove", + "DELETE", + connectionPath, + ); + yield* expectForbidden( + target, + member, + "member org connection refresh", + "POST", + `${connectionPath}/refresh`, + ); + const connectionsAfterDenial = yield* adminClient.connections.list({ + query: { owner: "org", integration: seedIntegration }, + }); + expect( + connectionsAfterDenial.some((connection) => connection.name === deniedConnection), + "the denied connection create persisted nothing", + ).toBe(false); + expect( + connectionsAfterDenial.find((connection) => connection.name === seedConnection), + "the denied update/remove left the seeded connection unchanged", + ).toMatchObject({ description: null, owner: "org" }); + + yield* expectForbidden( + target, + member, + "member integration create", + "POST", + "/api/openapi/specs", + specPayload(deniedIntegration), + ); + yield* expectForbidden( + target, + member, + "member integration update", + "PATCH", + `/api/integrations/${seedIntegration}`, + { description: "denied" }, + ); + yield* expectForbidden( + target, + member, + "member integration remove", + "DELETE", + `/api/openapi/integrations/${seedIntegration}`, + ); + yield* expectForbidden( + target, + member, + "member integration health-check update", + "PUT", + `/api/integrations/${seedIntegration}/health-check`, + { spec: { operation: "ping.ping" } }, + ); + const integrationsAfterDenial = yield* adminClient.integrations.list(); + expect( + integrationsAfterDenial.some((integration) => integration.slug === deniedIntegration), + "the denied integration create persisted nothing", + ).toBe(false); + expect( + yield* adminClient.integrations.get({ params: { slug: seedIntegration } }), + "the denied integration mutations left the seeded integration unchanged", + ).toEqual(seedIntegrationBefore); + + const manualPayload = { + owner: "org" as const, + slug: deniedManualClient, + grant: "authorization_code" as const, + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + clientId: `${prefix}-denied-id`, + clientSecret: `${prefix}-denied-secret`, + }; + yield* expectForbidden( + target, + member, + "member manual OAuth client create", + "POST", + "/api/oauth/clients", + manualPayload, + ); + yield* expectForbidden( + target, + member, + "member dynamic OAuth client create", + "POST", + "/api/oauth/clients/register-dynamic", + { + owner: "org", + slug: deniedDcrClient, + registrationEndpoint: oauth.registrationEndpoint, + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: ["read"], + }, + ); + const clientsAfterDenial = yield* adminClient.oauth.listClients(); + expect( + clientsAfterDenial.some( + (client) => client.slug === deniedManualClient || client.slug === deniedDcrClient, + ), + "the denied OAuth client creates persisted nothing", + ).toBe(false); + expect( + clientsAfterDenial.some((client) => client.slug === seedClient), + "the seeded OAuth client survived denied creates", + ).toBe(true); + + yield* expectForbidden( + target, + member, + "member org policy create", + "POST", + "/api/policies", + { owner: "org", pattern: `${policyPrefix}.denied`, action: "approve" }, + ); + yield* expectForbidden( + target, + member, + "member org policy update", + "PATCH", + `/api/policies/${seedPolicy.id}`, + { owner: "org", action: "block" }, + ); + yield* expectForbidden( + target, + member, + "member org policy remove", + "DELETE", + `/api/policies/${seedPolicy.id}`, + { owner: "org" }, + ); + const policiesAfterDenial = yield* adminClient.policies.list(); + expect( + policiesAfterDenial.some((policy) => policy.pattern === `${policyPrefix}.denied`), + "the denied policy create persisted nothing", + ).toBe(false); + expect( + policiesAfterDenial.find((policy) => policy.id === seedPolicy.id), + "the denied policy update/remove left the seeded policy unchanged", + ).toMatchObject({ action: "approve", pattern: `${policyPrefix}.seed` }); + + const personal = yield* memberClient.connections.create({ + payload: { + owner: "user", + name: personalConnection, + integration: seedIntegration, + template: TEMPLATE, + value: `${prefix}-personal-token`, + }, + }); + expect(personal.owner, "a member may create a Personal connection").toBe("user"); + const personalUpdated = yield* memberClient.connections.update({ + params: { owner: "user", integration: seedIntegration, name: personalConnection }, + payload: { identityLabel: "Personal updated" }, + }); + expect( + personalUpdated.identityLabel, + "a member may update their Personal connection", + ).toBe("Personal updated"); + const personalRemoved = yield* memberClient.connections.remove({ + params: { owner: "user", integration: seedIntegration, name: personalConnection }, + }); + expect(personalRemoved.removed, "a member may remove their Personal connection").toBe( + true, + ); + + const adminCreated = yield* adminClient.connections.create({ + payload: { + owner: "org", + name: adminConnection, + integration: seedIntegration, + template: TEMPLATE, + value: `${prefix}-admin-token`, + }, + }); + expect(adminCreated.owner, "an admin may create a Workspace connection").toBe("org"); + const adminUpdated = yield* adminClient.connections.update({ + params: { owner: "org", integration: seedIntegration, name: adminConnection }, + payload: { description: "Admin updated" }, + }); + expect(adminUpdated.description, "an admin may PATCH a Workspace connection").toBe( + "Admin updated", + ); + const refreshed = yield* adminClient.connections.refresh({ + params: { owner: "org", integration: seedIntegration, name: adminConnection }, + }); + expect(refreshed.length, "an admin may refresh a Workspace connection").toBeGreaterThan( + 0, + ); + const adminRemoved = yield* adminClient.connections.remove({ + params: { owner: "org", integration: seedIntegration, name: adminConnection }, + }); + expect(adminRemoved.removed, "an admin may remove a Workspace connection").toBe(true); + + yield* adminClient.openapi.addSpec({ payload: specPayload(adminIntegration) }); + const integrationUpdated = yield* adminClient.integrations.update({ + params: { slug: adminIntegration }, + payload: { description: "Admin integration updated" }, + }); + expect(integrationUpdated.description, "an admin may update an integration").toBe( + "Admin integration updated", + ); + const healthSet = yield* adminClient.integrations.healthCheckSet({ + params: { slug: adminIntegration }, + payload: { spec: { operation: "ping.ping" } }, + }); + expect(healthSet.ok, "an admin may set an integration health check").toBe(true); + yield* adminClient.openapi.removeSpec({ params: { slug: adminIntegration } }); + + yield* adminClient.oauth.createClient({ + payload: { + ...manualPayload, + slug: adminManualClient, + clientId: `${prefix}-admin-id`, + clientSecret: `${prefix}-admin-secret`, + }, + }); + const dynamic = yield* adminClient.oauth.registerDynamic({ + payload: { + owner: "org", + slug: adminDcrClient, + registrationEndpoint: oauth.registrationEndpoint, + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: ["read"], + }, + }); + registeredDynamicClient = dynamic.client; + const dynamicRow = (yield* adminClient.oauth.listClients()).find( + (client) => client.slug === dynamic.client, + ); + expect( + dynamicRow, + "an admin may dynamically register a persisted Workspace OAuth client", + ).toMatchObject({ + owner: "org", + origin: { kind: "dynamic_client_registration" }, + }); + + const adminPolicy = yield* adminClient.policies.create({ + payload: { owner: "org", pattern: `${policyPrefix}.admin`, action: "approve" }, + }); + const policyUpdated = yield* adminClient.policies.update({ + params: { policyId: adminPolicy.id }, + payload: { owner: "org", action: "block" }, + }); + expect(policyUpdated.action, "an admin may update a Workspace tool policy").toBe("block"); + const policyRemoved = yield* adminClient.policies.remove({ + params: { policyId: adminPolicy.id }, + payload: { owner: "org" }, + }); + expect(policyRemoved.removed, "an admin may remove a Workspace tool policy").toBe(true); + }), + cleanup, + ); + }), + ); diff --git a/packages/core/api/src/admin/admin-users.test.ts b/packages/core/api/src/admin/admin-users.test.ts index 684c81ff6f..aec566c295 100644 --- a/packages/core/api/src/admin/admin-users.test.ts +++ b/packages/core/api/src/admin/admin-users.test.ts @@ -359,7 +359,6 @@ type UsersWithConnectionsBody = { }>; }>; }; - const ORG_A = "Bearer org_a_key"; /** diff --git a/packages/core/api/src/admin/service.ts b/packages/core/api/src/admin/service.ts index b1d9baba99..d314e12d3b 100644 --- a/packages/core/api/src/admin/service.ts +++ b/packages/core/api/src/admin/service.ts @@ -17,7 +17,6 @@ // --------------------------------------------------------------------------- import { Context, type Effect } from "effect"; - import { type AdminUserNotFound, type AdminUsersError, diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index dff1f65312..ca70b50ad5 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -23,6 +23,7 @@ import { IntegrationSlug, InternalError, InvalidConnectionInputError, + OrgWriteDeniedError, OAuthClientSlug, Owner, ProviderItemId, @@ -200,6 +201,7 @@ export const ConnectionsApi = HttpApiGroup.make("connections") ConnectionAlreadyExists, CredentialProviderNotRegistered, InvalidConnectionInput, + OrgWriteDeniedError, ], }), ) @@ -215,21 +217,21 @@ export const ConnectionsApi = HttpApiGroup.make("connections") params: ConnectionParams, payload: UpdateConnectionPayload, success: ConnectionResponse, - error: [InternalError, ConnectionNotFound], + error: [InternalError, ConnectionNotFound, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.delete("remove", "/connections/:owner/:integration/:name", { params: ConnectionParams, success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, ConnectionNotFound], + error: [InternalError, ConnectionNotFound, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.post("refresh", "/connections/:owner/:integration/:name/refresh", { params: ConnectionParams, success: Schema.Array(ToolResponse), - error: [InternalError, ConnectionNotFound, IntegrationNotFound], + error: [InternalError, ConnectionNotFound, IntegrationNotFound, OrgWriteDeniedError], }), ) // Run the integration's declared health check against a SAVED connection: is diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts index 6700c7a314..d5140d2dcc 100644 --- a/packages/core/api/src/integrations/api.ts +++ b/packages/core/api/src/integrations/api.ts @@ -19,6 +19,7 @@ import { IntegrationRemovalNotAllowedError, IntegrationSlug, InternalError, + OrgWriteDeniedError, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- @@ -138,14 +139,14 @@ export const IntegrationsApi = HttpApiGroup.make("integrations") params: IntegrationParams, payload: UpdateIntegrationPayload, success: IntegrationResponse, - error: [InternalError, IntegrationNotFound], + error: [InternalError, IntegrationNotFound, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.delete("remove", "/integrations/:slug", { params: IntegrationParams, success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, IntegrationRemovalNotAllowed], + error: [InternalError, IntegrationRemovalNotAllowed, OrgWriteDeniedError], }), ) .add( @@ -178,6 +179,6 @@ export const IntegrationsApi = HttpApiGroup.make("integrations") params: IntegrationParams, payload: SetHealthCheckPayload, success: Schema.Struct({ ok: Schema.Boolean }), - error: [InternalError, IntegrationNotFound], + error: [InternalError, IntegrationNotFound, OrgWriteDeniedError], }), ); diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 6734d0efc9..5d0eee3d41 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -28,6 +28,7 @@ import { OAuthSessionNotFoundError, OAuthStartError, OAuthState, + OrgWriteDeniedError, Owner, ProviderKey, TokenEndpointAuthMethodSchema, @@ -278,14 +279,14 @@ export const OAuthApi = HttpApiGroup.make("oauth") HttpApiEndpoint.post("createClient", "/oauth/clients", { payload: CreateClientPayload, success: CreateClientResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.post("registerDynamic", "/oauth/clients/register-dynamic", { payload: RegisterDynamicPayload, success: RegisterDynamicResponse, - error: [InternalError, OAuthRegisterDynamic], + error: [InternalError, OAuthRegisterDynamic, OrgWriteDeniedError], }), ) .add( @@ -299,21 +300,21 @@ export const OAuthApi = HttpApiGroup.make("oauth") params: RemoveClientParams, payload: RemoveClientPayload, success: RemoveClientResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.post("start", "/oauth/start", { payload: StartPayload, success: StartResponse, - error: [InternalError, OAuthStart], + error: [InternalError, OAuthStart, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.post("complete", "/oauth/complete", { payload: CompletePayload, success: ConnectionResponse, - error: [InternalError, OAuthComplete, OAuthSessionNotFound], + error: [InternalError, OAuthComplete, OAuthSessionNotFound, OrgWriteDeniedError], }), ) .add( diff --git a/packages/core/api/src/observability.test.ts b/packages/core/api/src/observability.test.ts index ffd79e8665..e709e3dfc7 100644 --- a/packages/core/api/src/observability.test.ts +++ b/packages/core/api/src/observability.test.ts @@ -7,7 +7,12 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Ref, Result } from "effect"; -import { StorageConnectionError, StorageError, UniqueViolationError } from "@executor-js/sdk/core"; +import { + CredentialWriteIncompleteError, + StorageConnectionError, + StorageError, + UniqueViolationError, +} from "@executor-js/sdk/core"; import { capture, ErrorCapture, InternalError } from "./observability"; @@ -71,6 +76,27 @@ describe("capture", () => { }), ); + it.effect("marks an incomplete credential write retryable without exposing details", () => + Effect.gen(function* () { + const { layer, seen } = yield* makeRecorder("trace-retry"); + const err = new CredentialWriteIncompleteError({ + message: "provider reference and owner stay internal", + cause: "provider detail", + }); + + const result = yield* Effect.flip(capture(Effect.fail(err))).pipe(Effect.provide(layer)); + + expect(result).toEqual( + new InternalError({ + traceId: "trace-retry", + retryable: true, + }), + ); + expect(Object.keys(result).sort()).toEqual(["_tag", "retryable", "traceId"]); + expect(yield* Ref.get(seen)).toHaveLength(1); + }), + ); + it.effect("empty traceId when no ErrorCapture is wired", () => Effect.gen(function* () { const err = new StorageError({ message: "nope", cause: undefined }); diff --git a/packages/core/api/src/observability.ts b/packages/core/api/src/observability.ts index 918c3bbd20..9ad2d66593 100644 --- a/packages/core/api/src/observability.ts +++ b/packages/core/api/src/observability.ts @@ -7,8 +7,8 @@ // and can decide what to do. Here, at the HTTP edge, we define: // // 1. `InternalError` — public opaque 500 schema, narrow by design -// (only `traceId`), so no internal cause/message/stack ever -// crosses the wire. +// (`traceId` plus an optional literal retry signal), so no internal +// cause/message/stack ever crosses the wire. // 2. `ErrorCapture` — pluggable service the host wires up (Sentry in // the cloud Worker, console in the CLI, in-memory in tests) to // record causes and return correlation ids. Optional; absent → @@ -71,9 +71,11 @@ const resolveCapture = Effect.serviceOption(ErrorCapture).pipe( ); /** - * HTTP-edge translator for `StorageFailure` on a single Effect. Two + * HTTP-edge translator for `StorageFailure` on a single Effect. Four * cases: * + * - `CredentialWriteIncompleteError` — a committed executor-owned write did + * not finish. Capture it and expose only `retryable: true` plus trace id. * - `StorageError` — known backend failure. Capture the cause via * `ErrorCapture`, fail with `InternalError({ traceId })`. * - `StorageConnectionError` — the database connection failed, so the @@ -96,6 +98,12 @@ export const capture = ( (eff as Effect.Effect).pipe( // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unique conflicts that reach the HTTP edge are unexpected defects captured by observabilityMiddleware Effect.catchTag("UniqueViolationError", (err) => Effect.die(err)), + Effect.catchTag("CredentialWriteIncompleteError", (err) => + resolveCapture.pipe( + Effect.flatMap((c) => c.captureException(Cause.fail(err))), + Effect.flatMap((traceId) => Effect.fail(new InternalError({ traceId, retryable: true }))), + ), + ), Effect.catchTag("StorageError", (err) => resolveCapture.pipe( Effect.flatMap((c) => c.captureException(Cause.fail(err))), diff --git a/packages/core/api/src/policies/api.ts b/packages/core/api/src/policies/api.ts index a3b9f76de2..5f7915265a 100644 --- a/packages/core/api/src/policies/api.ts +++ b/packages/core/api/src/policies/api.ts @@ -8,7 +8,13 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { InternalError, Owner, PolicyId, ToolPolicyActionSchema } from "@executor-js/sdk/shared"; +import { + InternalError, + OrgWriteDeniedError, + Owner, + PolicyId, + ToolPolicyActionSchema, +} from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // Params @@ -63,7 +69,7 @@ export const PoliciesApi = HttpApiGroup.make("policies") HttpApiEndpoint.post("create", "/policies", { payload: CreateToolPolicyPayload, success: ToolPolicyResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( @@ -71,7 +77,7 @@ export const PoliciesApi = HttpApiGroup.make("policies") params: PolicyParams, payload: UpdateToolPolicyPayload, success: ToolPolicyResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( @@ -79,6 +85,6 @@ export const PoliciesApi = HttpApiGroup.make("policies") params: PolicyParams, payload: RemoveToolPolicyPayload, success: Schema.Struct({ removed: Schema.Boolean }), - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ); diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts index eac50f175c..df3b0d9341 100644 --- a/packages/core/api/src/server/execution-stack-middleware.ts +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -246,6 +246,12 @@ export const makeExecutionStackMiddleware = < resolved.accountId, resolved.organizationId, resolved.organizationName, + { + orgWrites: + resolved.orgRoleModel === "none" || resolved.orgRole === "admin" + ? "allowed" + : "denied", + }, ).pipe( Effect.provide(options.stackLayer, { local: true }), Effect.provideService(RequestWebOrigin, { diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index d951f3b813..3aa0dd6b8d 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -27,7 +27,7 @@ import { Context, Effect, Layer } from "effect"; import type * as Cause from "effect/Cause"; import type { McpResource } from "@executor-js/host-mcp"; -import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk"; +import type { AnyPlugin, Executor, ExecutorConfig, StorageFailure } from "@executor-js/sdk"; import { createExecutionEngine, type ExecutionEngine, @@ -112,7 +112,12 @@ export const makeExecutionStack = < accountId: string, organizationId: string, organizationName: string, - options?: { readonly mcpResource?: McpResource }, + options?: { + readonly mcpResource?: McpResource; + /** Workspace-settings permission for this binding (see + * `ExecutorConfig.orgWrites`), derived from the acting member's role. */ + readonly orgWrites?: ExecutorConfig["orgWrites"]; + }, ): Effect.Effect< { readonly executor: Executor; readonly engine: ExecutionEngine }, StorageFailure, @@ -123,10 +128,17 @@ export const makeExecutionStack = < accountId, organizationId, organizationName, - { plugins: { mcpResource: options?.mcpResource } }, + { + plugins: { mcpResource: options?.mcpResource }, + ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), + }, + ).pipe(Effect.withSpan("executor.stack.scoped_executor")); + const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( + Effect.withSpan("executor.stack.code_executor"), + ); + const { decorate } = yield* EngineDecorator.asEffect().pipe( + Effect.withSpan("executor.stack.decorator"), ); - const codeExecutor = yield* CodeExecutorProvider.asEffect(); - const { decorate } = yield* EngineDecorator.asEffect(); const engine = yield* Effect.sync(() => decorate( createExecutionEngine({ executor, codeExecutor }), diff --git a/packages/core/api/src/server/identity.ts b/packages/core/api/src/server/identity.ts index 04e4f23101..4c88040a96 100644 --- a/packages/core/api/src/server/identity.ts +++ b/packages/core/api/src/server/identity.ts @@ -28,7 +28,7 @@ import { Context, Effect, Schema } from "effect"; * original `Principal` is the model — it carries `organizationName` (cloud's * resolver already yielded it) AND `roles` (cloud supplies `[]`). */ -export interface Principal { +interface PrincipalBase { /** Discriminant of {@link ResolvedPrincipal}: an acting member, as opposed * to the org-level `"platform"` credential. Required so every construction * site declares which arm it is, and the union matches on a literal tag @@ -50,6 +50,31 @@ export interface Principal { readonly roles: readonly string[]; } +/** + * The provider-neutral resolved member identity. The role-model discriminant + * makes it impossible for a role-less host to carry an authorizing org role. + */ +export type Principal = PrincipalBase & + ( + | { + readonly orgRoleModel: "organization"; + /** + * The member's NORMALIZED workspace role for an `"organization"` model: + * `"admin"` may configure workspace-level state (org-owned rows, the + * integration catalog), `"member"` may only use it. Cloud maps its WorkOS + * membership role (`admin` / `member`); self-host maps Better Auth's org + * membership role (`owner` and `admin` → `"admin"`). It may be absent only + * at a legacy serialized boundary; an organization role model then denies + * workspace writes. A host without roles declares `orgRoleModel: "none"`. + */ + readonly orgRole?: "admin" | "member"; + } + | { + readonly orgRoleModel: "none"; + readonly orgRole?: never; + } + ); + /** * An ORG-level credential (cloud's org-scoped API key), resolved. Deliberately * NOT a `Principal`: a `Principal` names an acting member, and this credential diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3b9302faca..2512436557 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -48,7 +48,10 @@ export const makeMcpBuildServer = principal.accountId, principal.organizationId, principal.organizationName, - { mcpResource: options?.resource }, + { + mcpResource: options?.resource, + orgWrites: "request", + }, ).pipe(Effect.withSpan("mcp.execution_stack.build")); // Read inside the provided boundary: `webBaseUrl` is a host seam, and // hosts that can't know their public URL at boot leave it unset — in diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 426363c6b2..98aa91b25f 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -253,7 +253,13 @@ export const makeScopedExecutor = < // `EngineStackIdentity` (the engine decorator still wants it); not part of the // v2 executor binding, which is `{ tenant, subject }` only. _organizationName: string, - options?: { readonly plugins?: PluginsProviderContext }, + options?: { + readonly plugins?: PluginsProviderContext; + /** Workspace-settings permission for this binding (see + * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's + * role; omitted -> allowed (hosts with no role model). */ + readonly orgWrites?: ExecutorConfig["orgWrites"]; + }, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { const { db, blobs } = yield* DbProvider.asEffect(); @@ -316,6 +322,7 @@ export const makeScopedExecutor = < ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}), ...(config.waitUntil !== undefined ? { waitUntil: config.waitUntil } : {}), onElicitation: "accept-all", + ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), redirectUri, oauthCallbackStateOrgSlug: orgSlug, firstPartyOAuthClients: config.firstPartyOAuthClients, diff --git a/packages/core/execution/src/engine.test.ts b/packages/core/execution/src/engine.test.ts index e05c9f8c97..06b9e20888 100644 --- a/packages/core/execution/src/engine.test.ts +++ b/packages/core/execution/src/engine.test.ts @@ -1,8 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Exit } from "effect"; - -import { createExecutor, definePlugin } from "@executor-js/sdk"; +import { Cause, Data, Deferred, Effect, Exit, Fiber, Ref, Schema } from "effect"; + +import { + CurrentOrgWriteAccess, + createExecutor, + definePlugin, + makeOrgWriteAccessState, + tool, +} from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core"; import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine"; @@ -87,6 +94,143 @@ describe("executeWithPause failure propagation", () => { ); }); +describe("paused execution authorization", () => { + it.effect("uses the resumer's current org-write access after approval", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ coreTools: {}, orgWrites: "request" }), + ); + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor(), + }); + yield* Effect.addFinalizer(() => + engine.shutdown.pipe(Effect.andThen(executor.close()), Effect.ignore), + ); + + const started = yield* engine + .executeWithPause( + ` + return await tools.executor.coreTools.policies.create({ + owner: "org", + pattern: "resume-demotion-regression.*", + action: "block", + }); + `, + ) + .pipe(Effect.provideService(CurrentOrgWriteAccess, makeOrgWriteAccessState("allowed"))); + expect(started.status).toBe("paused"); + if (started.status !== "paused") return; + + const resumed = yield* engine + .resume(started.execution.id, { action: "accept", content: {} }) + .pipe(Effect.provideService(CurrentOrgWriteAccess, makeOrgWriteAccessState("denied"))); + expect(resumed?.status).toBe("completed"); + if (resumed?.status !== "completed") return; + expect(yield* executor.policies.list()).toEqual([]); + expect(resumed.result.result).toEqual({ + ok: false, + error: expect.objectContaining({ code: "org_write_denied" }), + }); + }).pipe(Effect.scoped), + ); + + it.effect("rebinds an in-flight resume when a demoted browser decision joins it", () => + Effect.gen(function* () { + const waitStarted = yield* Deferred.make(); + const releaseWait = yield* Deferred.make(); + let writeWorkspacePolicy: () => Effect.Effect = () => + Effect.die("workspace policy sink was not initialized"); + const joinPlugin = definePlugin(() => ({ + id: "resume-join-test" as const, + storage: () => ({}), + staticIntegrations: () => [ + { + id: "resumeJoinTest.latch", + kind: "in-memory" as const, + name: "Resume latch", + tools: [ + tool({ + name: "write", + description: "Hold a resumed execution while another resume joins it.", + annotations: { requiresApproval: true } as const, + inputSchema: Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({})), + ), + execute: () => + Deferred.succeed(waitStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseWait)), + Effect.andThen(Effect.suspend(writeWorkspacePolicy)), + ), + }), + ], + }, + ], + })); + const executor = yield* createExecutor( + makeTestConfig({ + coreTools: {}, + orgWrites: "request", + plugins: [joinPlugin()] as const, + }), + ); + writeWorkspacePolicy = () => + executor.policies.create({ + owner: "org", + pattern: "resume-join-demotion.*", + action: "block", + }); + const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); + yield* Effect.addFinalizer(() => + engine.shutdown.pipe(Effect.andThen(executor.close()), Effect.ignore), + ); + + const executionAccess = makeOrgWriteAccessState("allowed"); + const started = yield* engine + .executeWithPause( + ` + return await tools.resumeJoinTest.latch.write({}); + `, + ) + .pipe(Effect.provideService(CurrentOrgWriteAccess, executionAccess)); + expect(started.status).toBe("paused"); + if (started.status !== "paused") return; + + const first = yield* engine + .resume(started.execution.id, { action: "accept", content: {} }) + .pipe( + Effect.provideService(CurrentOrgWriteAccess, makeOrgWriteAccessState("allowed")), + Effect.forkChild, + ); + yield* Deferred.await(waitStarted); + + const joined = yield* engine + .resume(started.execution.id, { action: "accept", content: {} }) + .pipe( + Effect.provideService(CurrentOrgWriteAccess, makeOrgWriteAccessState("denied")), + Effect.forkChild, + ); + while ((yield* Ref.get(executionAccess.current)) !== "denied") { + yield* Effect.yieldNow; + } + yield* Deferred.succeed(releaseWait, undefined); + + const [firstOutcome, joinedOutcome] = yield* Effect.all([ + Fiber.join(first), + Fiber.join(joined), + ]); + expect(firstOutcome?.status).toBe("completed"); + expect(joinedOutcome).toEqual(firstOutcome); + expect(yield* executor.policies.list()).toEqual([]); + if (firstOutcome?.status !== "completed") return; + expect(firstOutcome.result.result).toEqual({ + ok: false, + error: expect.objectContaining({ code: "org_write_denied" }), + }); + }).pipe(Effect.scoped), + ); +}); + describe("pausedExecutionCount", () => { it.effect("starts at zero", () => Effect.gen(function* () { @@ -106,7 +250,11 @@ describe("formatPausedExecution approval terms", () => { const paused = (request: FormElicitation) => ({ id: "exec_1", - elicitationContext: { address: "tools.x.org.default.y", args: {}, request }, + elicitationContext: { + address: "tools.x.org.default.y", + args: {}, + request, + }, }) as Parameters[0]; it("states the terms an upstream attached to the approval", () => { @@ -152,7 +300,11 @@ describe("formatExecuteResult output identity", () => { const formatted = formatExecuteResult({ result: value, logs: [] }); expect(formatted.text).toBe(JSON.stringify(value, null, 2)); - expect(formatted.structured).toEqual({ status: "completed", result: value, logs: [] }); + expect(formatted.structured).toEqual({ + status: "completed", + result: value, + logs: [], + }); expect(formatted.structured["result"]).toBe(value); expect(formatted.isError).toBe(false); }); @@ -171,7 +323,10 @@ describe("formatExecuteResult output identity", () => { }); it("returns a string result verbatim", () => { - const formatted = formatExecuteResult({ result: "plain — ✓", logs: ["l1", "l2"] }); + const formatted = formatExecuteResult({ + result: "plain — ✓", + logs: ["l1", "l2"], + }); expect(formatted.text).toBe("plain — ✓\n\nLogs:\nl1\nl2"); expect(formatted.structured).toEqual({ @@ -248,7 +403,9 @@ describe("execute outcome measurement cost", () => { // autoApprove runs the inline path (inner span annotation) and then // annotates the outer pausable span with the same result. - const outcome = yield* engine.executeWithPause("noop", { autoApprove: true }); + const outcome = yield* engine.executeWithPause("noop", { + autoApprove: true, + }); expect(outcome.status).toBe("completed"); expect(fixture.walks()).toBe(1); diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 5113f9f462..8bb9bda071 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Fiber, Predicate, Queue } from "effect"; +import { Deferred, Effect, Fiber, Predicate, Queue, Ref } from "effect"; import type * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; @@ -9,6 +9,7 @@ import type { ElicitationHandler, ElicitationContext, } from "@executor-js/sdk/core"; +import { CurrentOrgWriteAccess, type OrgWriteAccessState } from "@executor-js/sdk/core"; import { CodeExecutionError } from "@executor-js/codemode-core"; import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor-js/codemode-core"; @@ -49,6 +50,7 @@ export type PausedExecutionDeadline = { /** Internal representation with Effect runtime state for pause/resume. */ type InternalPausedExecution = PausedExecution & { readonly response: Deferred.Deferred; + readonly orgWriteAccess: OrgWriteAccessState; readonly fiber: Fiber.Fiber; readonly pauseQueue: Queue.Queue>; }; @@ -99,7 +101,10 @@ const executeOutcomeAttributes = (result: ExecuteResult): Record total + line.length, 0) ?? 0, "mcp.execute.emitted": result.output?.length ?? 0, ...(result.error - ? { "mcp.execute.outcome": "fail", "mcp.execute.error_kind": result.errorKind ?? "unknown" } + ? { + "mcp.execute.outcome": "fail", + "mcp.execute.error_kind": result.errorKind ?? "unknown", + } : { "mcp.execute.outcome": "ok" }), }; executeOutcomeAttributesCache.set(result, attributes); @@ -363,7 +368,10 @@ const makeFullInvoker = ( }) .pipe( Effect.withSpan("mcp.tool.dispatch", { - attributes: { "mcp.tool.name": path, "executor.tool.builtin": true }, + attributes: { + "mcp.tool.name": path, + "executor.tool.builtin": true, + }, }), ); } @@ -407,7 +415,10 @@ const makeFullInvoker = ( offset, }).pipe( Effect.withSpan("mcp.tool.dispatch", { - attributes: { "mcp.tool.name": path, "executor.tool.builtin": true }, + attributes: { + "mcp.tool.name": path, + "executor.tool.builtin": true, + }, }), ); } @@ -421,7 +432,11 @@ const makeFullInvoker = ( } if (typeof args.path !== "string" || args.path.trim().length === 0) { - return Effect.fail(new ExecutionToolError({ message: "describe.tool requires a path" })); + return Effect.fail( + new ExecutionToolError({ + message: "describe.tool requires a path", + }), + ); } if ("includeSchemas" in args) { @@ -557,7 +572,13 @@ export const createExecutionEngine = >(); + const pendingResumes = new Map< + string, + { + readonly outcome: Deferred.Deferred; + readonly orgWriteAccess: OrgWriteAccessState; + } + >(); // Exits (not just successes) so a replayed failure re-fails through the // typed channel — hosts render engine failures opaquely, and a replay must @@ -596,10 +617,20 @@ export const createExecutionEngine = => Effect.raceFirst( Fiber.join(fiber).pipe( - Effect.map((result): ExecutionResult => ({ status: "completed", result })), + Effect.map( + (result): ExecutionResult => ({ + status: "completed", + result, + }), + ), ), Queue.take(pauseQueue).pipe( - Effect.map((paused): ExecutionResult => ({ status: "paused", execution: paused })), + Effect.map( + (paused): ExecutionResult => ({ + status: "paused", + execution: paused, + }), + ), ), ); @@ -623,7 +654,9 @@ export const createExecutionEngine = >(); + const orgWriteAccess = yield* CurrentOrgWriteAccess; // Will be set once the fiber is forked. let fiber: Fiber.Fiber; @@ -648,6 +682,7 @@ export const createExecutionEngine = ({ status: "completed", result }), + (result): ExecutionResult => ({ + status: "completed", + result, + }), ); for (const [id, paused] of pausedExecutions) { if (paused.fiber !== sandboxFiber) continue; @@ -720,7 +758,9 @@ export const createExecutionEngine = (); - pendingResumes.set(executionId, inflight); + pendingResumes.set(executionId, { + outcome: inflight, + orgWriteAccess: paused.orgWriteAccess, + }); + + // The detached sandbox inherited the starter's request context. Replace + // its per-execution authorization before waking any continuation so every + // accepted form/confirmation, decline, and cancellation is governed by + // the principal making this resume request rather than by the starter. + const resumeOrgWriteAccess = yield* CurrentOrgWriteAccess; + yield* Ref.set(paused.orgWriteAccess.current, yield* Ref.get(resumeOrgWriteAccess.current)); yield* Deferred.succeed(paused.response, { action: response.action as typeof ElicitationResponse.Type.action, diff --git a/packages/core/sdk/src/api-errors.ts b/packages/core/sdk/src/api-errors.ts index 9532c38b8c..d866c762e0 100644 --- a/packages/core/sdk/src/api-errors.ts +++ b/packages/core/sdk/src/api-errors.ts @@ -9,12 +9,15 @@ import { Schema } from "effect"; -/** Public 500 surface. Opaque by schema — only `traceId` crosses the wire. */ +/** Public 500 surface. Opaque by schema: correlation plus an optional safe + * retry signal are the only details that cross the wire. */ export class InternalError extends Schema.TaggedErrorClass()( "InternalError", { /** Opaque correlation id for backend lookup (Sentry event id, log line, etc.). */ traceId: Schema.String, + /** Present only when repeating the same user action can complete safely. */ + retryable: Schema.optional(Schema.Literal(true)), }, { httpApiStatus: 500 }, ) {} diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index 3be5f91e34..51c7f8d110 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -60,6 +60,7 @@ const memoryProvider = (): CredentialProvider => { }; const INTEG = IntegrationSlug.make("vercel"); +const COLLIDING_INTEG = IntegrationSlug.make("reserved-slug-collision"); const TEMPLATE = AuthTemplateSlug.make("apiKey"); /** Wrap a test `FumaDb` so every transaction it opens is observable. The @@ -112,6 +113,27 @@ const demoPlugin = definePlugin(() => ({ }), invokeTool: ({ toolRow, credential }) => Effect.succeed({ ran: toolRow.name, value: credential.value }), + describeAuthMethods: (integration) => + String(integration.slug) === String(COLLIDING_INTEG) + ? [ + { + id: "none", + label: "API key with a legacy colliding slug", + kind: "apikey", + template: "none", + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], + }, + ] + : [ + { + id: String(TEMPLATE), + label: "API key", + kind: "apikey", + template: String(TEMPLATE), + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], + }, + { id: "none", label: "No authentication", kind: "none", template: "none" }, + ], extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ @@ -119,12 +141,24 @@ const demoPlugin = definePlugin(() => ({ description: "Vercel", config: {}, }), + seedCollidingIntegration: () => + ctx.core.integrations.register({ + slug: COLLIDING_INTEG, + description: "Legacy colliding auth method", + config: {}, + }), resolveValue: (owner: "org" | "user", name: string) => ctx.connections.resolveValue({ owner, integration: INTEG, name: ConnectionName.make(name), }), + resolveCollidingValue: (owner: "org" | "user", name: string) => + ctx.connections.resolveValue({ + owner, + integration: COLLIDING_INTEG, + name: ConnectionName.make(name), + }), }), }))(); @@ -347,6 +381,133 @@ describe("connections.create", () => { ), ); + it.effect("a post-commit gap is fail-closed and a later runtime retries the stranded row", () => + Effect.scoped( + Effect.gen(function* () { + const firstWriteEntered = yield* Deferred.make(); + const releaseFirstWrite = yield* Deferred.make(); + const store = new Map([ + ["connection:org:vercel:main:token", "orphaned-predecessor"], + ]); + let writes = 0; + let failNextDelete = false; + const provider: CredentialProvider = { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => + Effect.gen(function* () { + writes += 1; + if (writes === 1) { + yield* Deferred.succeed(firstWriteEntered, undefined); + yield* Deferred.await(releaseFirstWrite); + } + store.set(String(id), value); + }), + delete: (id) => + failNextDelete + ? Effect.sync(() => { + failNextDelete = false; + }).pipe( + Effect.andThen( + Effect.fail( + new StorageError({ message: "old item cleanup refused", cause: undefined }), + ), + ), + ) + : Effect.sync(() => void store.delete(String(id))), + }; + const plugin = definePlugin(() => ({ + id: "recoverable" as const, + credentialProviders: [provider], + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), + resolveValue: () => + ctx.connections.resolveValue({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("main"), + }), + }), + }))(); + const config = makeTestConfig({ plugins: [plugin] as const }); + const first = yield* createExecutor(config); + yield* first.recoverable.seed(); + + const infos: string[] = []; + const warnings: string[] = []; + const capture = Logger.make((options) => { + const message = Inspectable.toStringUnknown(options.message, 0); + if (options.logLevel === "Info") infos.push(message); + if (options.logLevel === "Warn") warnings.push(message); + }); + const logger = Logger.layer([capture]); + + const firstFiber = yield* Effect.forkChild( + first.connections + .create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + values: { + token: "first-attempt", + "extra:attempt:foreign": "collision-shaped-base", + }, + }) + .pipe(Effect.provide(logger)), + ); + yield* Deferred.await(firstWriteEntered); + + // The committed row points at its own missing attempt item. It must not + // fall back to the deterministic predecessor key already in the store. + const gapRead = yield* first.recoverable.resolveValue().pipe(Effect.result); + expect(Result.isFailure(gapRead)).toBe(true); + expect( + Result.match(gapRead, { + onFailure: (failure) => failure.message, + onSuccess: () => "", + }), + ).toContain("is incomplete; retry"); + expect(store.get("connection:org:vercel:main:token")).toBe("orphaned-predecessor"); + + // A fresh executor incarnation models restart after a crash that never + // runs the first hook. It recognizes the foreign missing attempt, + // atomically replaces the row with a new attempt, and succeeds. + const restarted = yield* createExecutor(config); + failNextDelete = true; + yield* restarted.connections + .create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + values: { + token: "retried-value", + "extra:attempt:foreign": "collision-shaped-base", + }, + }) + .pipe(Effect.provide(logger)); + expect(yield* restarted.recoverable.resolveValue()).toBe("retried-value"); + expect(infos.some((line) => line.includes("stranded row detected"))).toBe(true); + expect(infos.some((line) => line.includes("stranded row replaced"))).toBe(true); + expect(warnings.some((line) => line.includes("replaced row cleanup failed"))).toBe(true); + + // If the old process comes back, its unique write is inert and the row + // identity check reports that the attempt was superseded. + yield* Deferred.succeed(releaseFirstWrite, undefined); + expect(Exit.isFailure(yield* Fiber.await(firstFiber))).toBe(true); + expect(infos.some((line) => line.includes("credential write superseded"))).toBe(true); + expect(yield* restarted.recoverable.resolveValue()).toBe("retried-value"); + }), + ), + ); + // When both creates observe absence, both reach the insert and the primary // key breaks the tie — the loser must still get the typed 409, not a raw // unique-constraint storage failure. The proxy blinds every connection-table @@ -464,6 +625,37 @@ describe("connections.create", () => { }), ); + it.effect("an external reference containing the old attempt marker is never replaced", () => + Effect.gen(function* () { + const executor = yield* setup(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("byo"), + integration: INTEG, + template: TEMPLATE, + from: { + provider: ProviderKey.make("memory"), + id: ProviderItemId.make("vault:attempt:foreign:item"), + }, + }); + + const duplicate = yield* Effect.result( + executor.connections.create({ + owner: "org", + name: ConnectionName.make("byo"), + integration: INTEG, + template: TEMPLATE, + value: "must-not-replace", + }), + ); + + expect(Result.isFailure(duplicate)).toBe(true); + if (!Result.isFailure(duplicate)) return; + expect(duplicate.failure).toBeInstanceOf(ConnectionAlreadyExistsError); + expect(yield* executor.demo.resolveValue("org", "byo")).toBeNull(); + }), + ); + it.effect("create on an unknown integration fails with IntegrationNotFoundError", () => Effect.gen(function* () { const executor = yield* setup(); @@ -532,10 +724,10 @@ describe("connections.create", () => { }), ); - // The no-auth template: public servers need no credential. The UI submits - // `values: {}` for them and the persisted row carries an empty `item_ids` - // map — that is the canonical shape (every migrated no-auth connection in - // prod has it), so it must create cleanly and keep its tools on refresh. + // A no-auth method needs no credential. SDK callers may submit `values: {}`; + // the dashboard's legacy shape is `values: { token: "" }`. Both canonicalize + // to an empty `item_ids` map (the shape of migrated no-auth connections), so + // creation and refresh must keep the connection's tools. it.effect('creates a no-auth (`template: "none"`) connection from an empty `values` map', () => Effect.gen(function* () { const executor = yield* setup(); @@ -562,6 +754,79 @@ describe("connections.create", () => { }), ); + it.effect("normalizes a no-auth scalar placeholder to an empty credential binding", () => + Effect.gen(function* () { + const executor = yield* setup(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("public-scalar"), + integration: INTEG, + template: AuthTemplateSlug.make("none"), + value: "", + }); + + expect(yield* executor.demo.resolveValue("org", "public-scalar")).toBeNull(); + }), + ); + + it.effect("normalizes the dashboard's no-auth empty-value placeholder", () => + Effect.gen(function* () { + const executor = yield* setup(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("public-dashboard"), + integration: INTEG, + template: AuthTemplateSlug.make("none"), + values: { token: "" }, + }); + + expect(yield* executor.demo.resolveValue("org", "public-dashboard")).toBeNull(); + }), + ); + + it.effect("rejects a real credential for a resolved no-auth method", () => + Effect.gen(function* () { + const executor = yield* setup(); + const result = yield* Effect.result( + executor.connections.create({ + owner: "org", + name: ConnectionName.make("public-with-secret"), + integration: INTEG, + template: AuthTemplateSlug.make("none"), + value: "must-not-be-dropped", + }), + ); + + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(result.failure).toMatchObject({ + _tag: "InvalidConnectionInputError", + message: "A no-auth connection cannot accept credential inputs.", + }); + expect(yield* executor.connections.list()).toEqual([]); + }), + ); + + it.effect("preserves credentials for an API-key method with a colliding no-auth slug", () => + Effect.gen(function* () { + const executor = yield* setup(); + yield* executor.demo.seedCollidingIntegration(); + const integration = yield* executor.integrations.get(COLLIDING_INTEG); + expect(integration?.authMethods).toMatchObject([{ kind: "apikey", template: "none" }]); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("legacy-api-key"), + integration: COLLIDING_INTEG, + template: AuthTemplateSlug.make("none"), + value: "preserved-secret", + }); + + expect(yield* executor.demo.resolveCollidingValue("org", "legacyApiKey")).toBe( + "preserved-secret", + ); + }), + ); + it.effect("allows an empty-string value (no-auth integrations bind one)", () => Effect.gen(function* () { const executor = yield* setup(); @@ -1051,8 +1316,8 @@ describe("connections.create credential-write compensation", () => { const rows = yield* executor.connections.list(); expect(rows.length).toBe(1); expect(String(rows[0]?.name)).toBe("main"); - expect(store.get("connection:org:vercel:main:first")).toBe("c-1"); - expect(store.get("connection:org:vercel:main:second")).toBe("c-2"); + expect([...store.values()]).toContain("c-1"); + expect([...store.values()]).toContain("c-2"); }), ), ); @@ -1144,8 +1409,8 @@ describe("connections.create credential-write compensation", () => { const rows = yield* executor.connections.list(); expect(rows.length).toBe(1); expect(String(rows[0]?.name)).toBe("main"); - expect(store.get("connection:org:vercel:main:first")).toBe("c-1"); - expect(store.get("connection:org:vercel:main:second")).toBe("c-2"); + expect([...store.values()]).toContain("c-1"); + expect([...store.values()]).toContain("c-2"); expect(infos.some((line) => line.includes("removed nothing"))).toBe(true); }), ), @@ -1221,7 +1486,7 @@ describe("connections.create credential-write compensation", () => { // The unknown outcome skips ALL credential-item deletion: the item that // landed before the failed write is untouched. - expect(store.get("connection:org:vercel:main:first")).toBe("1"); + expect([...store.entries()].find(([id]) => id.endsWith(":first"))?.[1]).toBe("1"); // The log reports the unconfirmed state, not a stranded-row claim. expect(errors.some((line) => line.includes("could not confirm"))).toBe(true); expect(errors.every((line) => !line.includes("stranded a connection row"))).toBe(true); @@ -1292,7 +1557,7 @@ describe("connections.create credential-write compensation", () => { // The unknown outcome skips ALL credential-item deletion: the item that // landed before the failed write is untouched. - expect(store.get("connection:org:vercel:main:first")).toBe("1"); + expect([...store.entries()].find(([id]) => id.endsWith(":first"))?.[1]).toBe("1"); // The log reports the unconfirmed state, not a stranded-row claim. expect(errors.some((line) => line.includes("could not confirm"))).toBe(true); expect(errors.every((line) => !line.includes("stranded a connection row"))).toBe(true); @@ -2787,6 +3052,7 @@ describe("heal-on-use", () => { // exists, and healing from it would tell the user to stop reconnecting. yield* stamp({ item_ids: { token: "vanished-item" }, + credential_write: null, last_health: { status: "expired", checkedAt: Date.now() - STALE_MS, detail: "HTTP 401" }, }); diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8f0fe9b0fd..8014584695 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -210,6 +210,11 @@ export const coreTables = defineTables({ template: textColumn("template"), provider: textColumn("provider"), item_ids: jsonColumn("item_ids"), + // Executor ownership for this row's credential references, as JSON + // `{ runtimeId: string, attemptId: string }` (see + // credential-item-reference.ts). Null means every provider item id is + // external/legacy and therefore opaque to core — never repairable. + credential_write: nullableJsonColumn("credential_write"), identity_label: nullableTextColumn("identity_label"), // User-curated, agent-visible "what is this connection for". Settable at // create, editable after; never reset by OAuth re-mints. @@ -261,6 +266,11 @@ export const coreTables = defineTables({ // (WorkOS Vault on cloud, the local store on desktop). Null for public / // PKCE clients (no secret). Keeps secrets out of plaintext columns. client_secret_item_id: nullableTextColumn("client_secret_item_id"), + // Executor ownership for `client_secret_item_id`, as JSON + // `{ runtimeId: string, attemptId: string }` (see + // credential-item-reference.ts). Null means the provider reference is + // external/legacy and therefore opaque to core — never repairable. + credential_write: nullableJsonColumn("credential_write"), // Null in old rows means client_secret_post (the existing default). // Stored values are "body" or "basic" and are validated on read. token_endpoint_auth_method: nullableTextColumn("token_endpoint_auth_method"), diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 82e2b8fe5e..461e04d8a9 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -14,7 +14,6 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug, - NO_AUTH_TEMPLATE, OAuthClientSlug, OAuthState, ProviderItemId, @@ -141,18 +140,11 @@ const ConnectionCreateInput = Schema.Struct({ Schema.makeFilter((payload) => { const originCount = (payload.from === undefined ? 0 : 1) + (payload.inputs === undefined ? 0 : 1); - // The no-auth template ("none") binds zero credentials — both `from` and - // `inputs` are legitimately absent (public MCP servers, public REST APIs). - // Mirror the engine, which accepts an empty input set only for this - // template; a stray origin would wire a credential the connection can't - // hold, so reject any. Every other template needs exactly one origin. - const isNoAuth = String(payload.template) === String(NO_AUTH_TEMPLATE); - if (isNoAuth) { - if (originCount > 0) { - return 'A no-auth connection (template "none") takes no provider credential origin'; - } - } else if (originCount !== 1) { - return "Expected exactly one provider credential origin"; + // Auth meaning belongs to the integration's resolved method descriptor, + // which the engine evaluates with catalog context. This boundary only + // rejects an ambiguous shape that supplies two competing origins. + if (originCount > 1) { + return "Expected at most one provider credential origin"; } if (payload.inputs !== undefined && Object.keys(payload.inputs).length === 0) { return "Expected at least one provider credential input"; diff --git a/packages/core/sdk/src/credential-compensation.test.ts b/packages/core/sdk/src/credential-compensation.test.ts new file mode 100644 index 0000000000..a01c258ff4 --- /dev/null +++ b/packages/core/sdk/src/credential-compensation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Predicate } from "effect"; + +import { restoreCredentialSnapshotsWithRecheck } from "./credential-compensation"; +import { StorageError } from "./fuma-runtime"; +import { ProviderItemId } from "./ids"; + +describe("restoreCredentialSnapshotsWithRecheck", () => { + it.effect("attempts every restore and reports their combined failed outcome", () => + Effect.gen(function* () { + const attempts: string[] = []; + const failure = new StorageError({ message: "first restore refused", cause: undefined }); + const outcome = yield* restoreCredentialSnapshotsWithRecheck( + [ + { + itemId: ProviderItemId.make("first"), + value: "new-first", + write: Effect.void, + restoreSupported: true, + restore: Effect.sync(() => attempts.push("first")).pipe( + Effect.andThen(Effect.fail(failure)), + ), + }, + { + itemId: ProviderItemId.make("second"), + value: "new-second", + write: Effect.void, + restoreSupported: true, + restore: Effect.sync(() => { + attempts.push("second"); + }), + }, + ], + Effect.succeed(true), + ); + + expect(attempts).toEqual(["first", "second"]); + expect(Predicate.isTagged(outcome, "Failed")).toBe(true); + if (!Predicate.isTagged(outcome, "Failed")) return; + expect( + outcome.cause.reasons.filter(Cause.isFailReason).map((reason) => reason.error), + ).toEqual([failure]); + }), + ); +}); diff --git a/packages/core/sdk/src/credential-compensation.ts b/packages/core/sdk/src/credential-compensation.ts new file mode 100644 index 0000000000..dba7960af4 --- /dev/null +++ b/packages/core/sdk/src/credential-compensation.ts @@ -0,0 +1,73 @@ +import { Cause, Effect, Exit } from "effect"; + +import type { StorageFailure } from "./fuma-runtime"; +import type { ProviderItemId } from "./ids"; +import type { CredentialProvider } from "./provider"; + +export interface CredentialWriteInput { + readonly itemId: ProviderItemId; + readonly value: string; +} + +export interface CredentialWriteSnapshot extends CredentialWriteInput { + readonly write: Effect.Effect; + readonly restore: Effect.Effect; + readonly restoreSupported: boolean; +} + +/** Snapshot provider values before a database commit makes their writes live. */ +export const snapshotCredentialWrites = ( + provider: CredentialProvider & { + readonly set: NonNullable; + }, + entries: readonly CredentialWriteInput[], + missingDeleteFailure: (itemId: ProviderItemId) => StorageFailure, + options?: { readonly requireDeleteForNew?: boolean }, +): Effect.Effect => + Effect.forEach(entries, (entry) => + Effect.gen(function* () { + const previous = yield* provider.get(entry.itemId); + if (previous === null && !provider.delete && options?.requireDeleteForNew === true) { + return yield* missingDeleteFailure(entry.itemId); + } + return { + ...entry, + write: provider.set(entry.itemId, entry.value), + restoreSupported: previous !== null || provider.delete !== undefined, + restore: + previous === null + ? provider.delete + ? provider.delete(entry.itemId) + : Effect.fail(missingDeleteFailure(entry.itemId)) + : provider.set(entry.itemId, previous), + }; + }), + ); + +export type CredentialRestoreOutcome = + | { readonly _tag: "Restored" } + | { readonly _tag: "Superseded" } + | { readonly _tag: "Failed"; readonly cause: Cause.Cause }; + +/** + * Re-check database ownership immediately before restoring provider values. + * The provider seam remains unconditional, so this narrows but cannot close + * the documented interval between the recheck and the provider operation. + */ +export const restoreCredentialSnapshotsWithRecheck = ( + snapshots: readonly CredentialWriteSnapshot[], + stillOwnsCompensatedState: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + if (!(yield* stillOwnsCompensatedState)) return { _tag: "Superseded" } as const; + const restoreExits = yield* Effect.forEach(snapshots, (snapshot) => + Effect.exit(snapshot.restore), + ); + const failureCause = restoreExits.reduce>( + (cause, exit) => (Exit.isFailure(exit) ? Cause.combine(cause, exit.cause) : cause), + Cause.empty, + ); + return failureCause.reasons.length === 0 + ? ({ _tag: "Restored" } as const) + : ({ _tag: "Failed", cause: failureCause } as const); + }); diff --git a/packages/core/sdk/src/credential-item-reference.test.ts b/packages/core/sdk/src/credential-item-reference.test.ts new file mode 100644 index 0000000000..5a07ba6499 --- /dev/null +++ b/packages/core/sdk/src/credential-item-reference.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + credentialAttemptItemId, + makeCredentialWriteAttempt, + parseCredentialWriteAttempt, +} from "./credential-item-reference"; + +describe("credential write attempt metadata", () => { + it("never classifies an opaque provider item id as executor-owned", () => { + expect(parseCredentialWriteAttempt("vault:attempt:foreign:item")).toBeNull(); + }); + + it("classifies generated references only through their separate metadata", () => { + const metadata = makeCredentialWriteAttempt("runtime-1", "attempt-1"); + const itemId = credentialAttemptItemId( + "connection:org:integration:name:extra:attempt:foreign", + metadata.attemptId, + ); + + expect(itemId).toContain(":attempt:"); + expect(parseCredentialWriteAttempt(itemId)).toBeNull(); + expect(parseCredentialWriteAttempt(metadata)).toEqual(metadata); + expect( + parseCredentialWriteAttempt('{"runtimeId":"runtime-1","attemptId":"attempt-1"}'), + ).toEqual(metadata); + }); +}); diff --git a/packages/core/sdk/src/credential-item-reference.ts b/packages/core/sdk/src/credential-item-reference.ts new file mode 100644 index 0000000000..12dedfcf78 --- /dev/null +++ b/packages/core/sdk/src/credential-item-reference.ts @@ -0,0 +1,39 @@ +import { Option, Schema } from "effect"; + +const CredentialWriteAttempt = Schema.Struct({ + runtimeId: Schema.String, + attemptId: Schema.String, +}); + +/** Persisted executor ownership metadata for one credential write attempt. */ +export type CredentialWriteAttempt = typeof CredentialWriteAttempt.Type; + +const decodeCredentialWriteAttempt = Schema.decodeUnknownOption(CredentialWriteAttempt); +const decodeCredentialWriteAttemptJson = Schema.decodeUnknownOption( + Schema.fromJsonString(CredentialWriteAttempt), +); + +/** Build the structured metadata stored alongside opaque provider item ids. */ +export const makeCredentialWriteAttempt = ( + runtimeId: string, + attemptId: string, +): CredentialWriteAttempt => ({ runtimeId, attemptId }); + +/** Parse persisted write-attempt metadata without inspecting a provider item id. */ +export const parseCredentialWriteAttempt = (value: unknown): CredentialWriteAttempt | null => + Option.getOrNull( + typeof value === "string" + ? decodeCredentialWriteAttemptJson(value) + : decodeCredentialWriteAttempt(value), + ); + +/** + * Give one logical credential slot a provider item id unique to one write + * attempt. This format is generation-only: provider ids remain opaque after + * creation and ownership is determined exclusively from persisted metadata. + */ +export const credentialAttemptItemId = (baseItemId: string, attemptId: string): string => { + const terminalSeparator = baseItemId.lastIndexOf(":"); + if (terminalSeparator < 0) return `${baseItemId}:${attemptId}`; + return `${baseItemId.slice(0, terminalSeparator)}:${attemptId}${baseItemId.slice(terminalSeparator)}`; +}; diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index a732dcc145..2e22ff0ca7 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -149,6 +149,32 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass()( + "OrgWriteDeniedError", + {}, + { httpApiStatus: 403 }, + ) + implements UserActionableError +{ + readonly __executorUserActionable = true; + readonly code = "org_write_denied"; + + override get message(): string { + return "Adding connections or changing workspace settings requires a workspace admin."; + } + + get userMessage(): string { + return this.message; + } +} + export class ConnectionNotFoundError extends Schema.TaggedErrorClass()( "ConnectionNotFoundError", { diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 2dc9bc9591..fb2835a529 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Predicate, Result, Scheduler } from "effect"; +import { Data, Effect, Inspectable, Logger, Predicate, Result, Scheduler } from "effect"; import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; import { ToolNotFoundError } from "./errors"; @@ -37,11 +37,13 @@ const memoryProvider = (): CredentialProvider => { writable: true, get: (id) => Effect.sync(() => store.get(String(id)) ?? null), set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + delete: (id) => Effect.sync(() => void store.delete(String(id))), }; }; const INTEG = IntegrationSlug.make("demo"); const PINNED = IntegrationSlug.make("demo-pinned"); +const COLLIDING_NONE_INTEG = IntegrationSlug.make("demo-legacy-none"); const TEMPLATE = AuthTemplateSlug.make("apiKey"); const CONN = ConnectionName.make("main"); @@ -91,6 +93,18 @@ const demoPlugin = definePlugin(() => ({ }, }), invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + describeAuthMethods: (integration) => + String(integration.slug) === String(COLLIDING_NONE_INTEG) + ? [ + { + id: "none", + label: "Legacy API key", + kind: "apikey", + template: "none", + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], + }, + ] + : [], extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ @@ -107,6 +121,12 @@ const demoPlugin = definePlugin(() => ({ config: {}, canRemove: false, }), + seedCollidingNone: () => + ctx.core.integrations.register({ + slug: COLLIDING_NONE_INTEG, + description: "Legacy colliding auth method", + config: {}, + }), storagePut: (owner: "org" | "user", key: string, value: string) => ctx.storage.put(owner, key, value), storageList: () => ctx.storage.list(), @@ -128,6 +148,22 @@ const demoPlugin = definePlugin(() => ({ const diagnosticsPlugin = definePlugin(() => ({ id: "diagnostics" as const, storage: () => ({}), + describeAuthMethods: () => [ + { + id: "none", + label: "No authentication", + kind: "none", + template: "none", + placements: [{ carrier: "header", name: "X-Invalid-No-Auth-Placement", prefix: "" }], + }, + { + id: "credential", + label: "Credential", + kind: "apikey", + template: "credential", + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], + }, + ], resolveTools: ({ connection }) => Effect.succeed({ tools: [], @@ -375,6 +411,58 @@ describe("createExecutor", () => { }), ); + it.effect("creates a provider-backed legacy slug-none connection through the core tool", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [demoPlugin] as const, + coreTools: { webBaseUrl: "http://localhost:3000" }, + }); + yield* executor.demo.seed(); + yield* executor.demo.seedCollidingNone(); + + const missingOrigin = yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.create"), + { + owner: "org", + name: "missing", + integration: String(INTEG), + template: String(TEMPLATE), + }, + ); + expect(missingOrigin).toEqual({ + ok: false, + error: { + code: "invalid_connection_input", + message: "A connection must supply at least one credential input.", + }, + }); + + const created = yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.create"), + { + owner: "org", + name: "legacy", + integration: String(COLLIDING_NONE_INTEG), + template: "none", + from: { provider: "memory", id: "legacy-secret" }, + }, + ); + expect(created).toMatchObject({ + owner: "org", + name: "legacy", + integration: String(COLLIDING_NONE_INTEG), + template: "none", + address: "tools.demo-legacy-none.org.legacy", + }); + + const invoked = yield* executor.execute( + ToolAddress.make("tools.demo-legacy-none.org.legacy.run"), + {}, + ); + expect(invoked).toEqual({ ran: "run" }); + }), + ); + it.effect("removes catalog integrations through the built-in Executor tools", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ @@ -435,24 +523,49 @@ describe("createExecutor", () => { }), ); - it.effect("surfaces failed tool sync diagnostics through connection tools", () => + it.effect("omits invalid auth methods and surfaces plugin and tool sync diagnostics", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, coreTools: {}, }); yield* executor.diagnostics.seed(); - - yield* executor.execute( - ToolAddress.make("executor.coreTools.connections.create"), + const warnings: string[] = []; + const capture = Logger.make((options) => { + if (options.logLevel === "Warn") { + warnings.push(Inspectable.toStringUnknown(options.message, 0)); + } + }); + const integration = yield* executor.integrations + .get(IntegrationSlug.make("diagnostics")) + .pipe(Effect.provide(Logger.layer([capture]))); + yield* executor.integrations + .get(IntegrationSlug.make("diagnostics")) + .pipe(Effect.provide(Logger.layer([capture]))); + expect(integration?.authMethods).toEqual([ { - owner: "org", - name: "main", - integration: "diagnostics", - template: "none", + id: "credential", + label: "Credential", + kind: "apikey", + template: "credential", + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], }, - { onElicitation: "accept-all" }, - ); + ]); + expect( + warnings.filter( + (line) => + line.includes("executor omitted invalid plugin auth method") && + line.includes("no-auth methods cannot declare credential placements"), + ), + ).toHaveLength(1); + + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("diagnostics"), + template: AuthTemplateSlug.make("credential"), + value: "diagnostics-credential", + }); const listed = yield* executor.execute( ToolAddress.make("executor.coreTools.connections.list"), @@ -496,16 +609,13 @@ describe("createExecutor", () => { }); yield* executor.diagnostics.seedExpired(); - yield* executor.execute( - ToolAddress.make("executor.coreTools.connections.create"), - { - owner: "org", - name: "main", - integration: "diagnostics_expired", - template: "none", - }, - { onElicitation: "accept-all" }, - ); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("diagnostics_expired"), + template: AuthTemplateSlug.make("credential"), + value: "diagnostics-credential", + }); const refreshed = yield* executor.execute( ToolAddress.make("executor.coreTools.connections.refresh"), diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index b31e60149c..6916233cb5 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -21,7 +21,9 @@ import { schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/sch import type { AnyColumn } from "@executor-js/fumadb/schema"; import { StorageError, + CredentialWriteIncompleteError, afterCommit, + afterCommitRequired, isStorageFailure, makeFumaClient, type FumaDb, @@ -71,6 +73,17 @@ import { type OnElicitation, type InvokeOptions, } from "./elicitation"; +import { currentOrgWriteAccess, type OrgWriteAccess } from "./org-write-access"; +import { + restoreCredentialSnapshotsWithRecheck, + snapshotCredentialWrites, + type CredentialWriteSnapshot, +} from "./credential-compensation"; +import { + credentialAttemptItemId, + makeCredentialWriteAttempt, + parseCredentialWriteAttempt, +} from "./credential-item-reference"; export type { OnElicitation, InvokeOptions } from "./elicitation"; import { @@ -93,6 +106,7 @@ import { InvalidConnectionInputError, IntegrationRemovalNotAllowedError, NoHandlerError, + OrgWriteDeniedError, PluginNotLoadedError, ToolBlockedError, ToolInvocationError, @@ -357,10 +371,13 @@ export type Executor = { readonly update: ( slug: IntegrationSlug, patch: { readonly name?: string; readonly description?: string }, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( slug: IntegrationSlug, - ) => Effect.Effect; + ) => Effect.Effect< + void, + IntegrationRemovalNotAllowedError | OrgWriteDeniedError | StorageFailure + >; readonly detect: ( url: string, ) => Effect.Effect; @@ -385,7 +402,7 @@ export type Executor = { readonly set: ( slug: IntegrationSlug, spec: HealthCheckSpec | null, - ) => Effect.Effect; + ) => Effect.Effect; }; }; @@ -398,6 +415,7 @@ export type Executor = { | ConnectionAlreadyExistsError | CredentialProviderNotRegisteredError | InvalidConnectionInputError + | OrgWriteDeniedError | StorageFailure >; readonly list: (filter?: { @@ -410,15 +428,15 @@ export type Executor = { readonly update: ( ref: ConnectionRef, input: UpdateConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( ref: ConnectionRef, - ) => Effect.Effect; + ) => Effect.Effect; readonly refresh: ( ref: ConnectionRef, ) => Effect.Effect< readonly Tool[], - ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure + ConnectionNotFoundError | IntegrationNotFoundError | OrgWriteDeniedError | StorageFailure >; /** Run the integration's declared health check against a saved connection: * classify the credential (healthy / expired / degraded / unknown) and @@ -460,9 +478,15 @@ export type Executor = { readonly policies: { readonly list: () => Effect.Effect; - readonly create: (input: CreateToolPolicyInput) => Effect.Effect; - readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; - readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; + readonly create: ( + input: CreateToolPolicyInput, + ) => Effect.Effect; + readonly update: ( + input: UpdateToolPolicyInput, + ) => Effect.Effect; + readonly remove: ( + input: RemoveToolPolicyInput, + ) => Effect.Effect; readonly resolve: (address: ToolAddress) => Effect.Effect; }; @@ -795,6 +819,29 @@ export interface ExecutorConfig { if ("inputs" in input) { - return Object.entries(input.inputs).map(([variable, origin]) => ({ variable, origin })); + return Object.entries(input.inputs).map(([variable, origin]) => ({ + variable, + origin, + })); } if ("values" in input) { return Object.entries(input.values).map(([variable, value]) => ({ @@ -1138,6 +1188,12 @@ const connectionItemIds = (row: ConnectionRow): Record => { return decoded as Record; }; +/** Read the storage surrogate retained on adapter results but hidden by FumaRow. */ +const storageRowId = (row: unknown): string | null => { + const value = row == null ? null : (row as Record)["row_id"]; + return typeof value === "string" ? value : null; +}; + // Accepts a projected row (the invoke/list paths select away the heavy // schema columns); `Tool.inputSchema`/`outputSchema` are optional and stay // absent for those callers — `tools.schema` is the schema-bearing surface. @@ -1857,6 +1913,21 @@ export const createExecutor = => + Effect.gen(function* () { + const access = + config.orgWrites === "request" ? yield* currentOrgWriteAccess : config.orgWrites; + if (access === "denied" && (owner === undefined || owner === "org")) { + return yield* new OrgWriteDeniedError(); + } + }); + // Built-in core-tools plugin: agent-facing static tools over the v2 surface. const plugins: readonly AnyPlugin[] = config.coreTools ? ([ @@ -1922,7 +1993,11 @@ export const createExecutor = (); const credentialProviderOrder: string[] = []; + // Identifies this live executor incarnation. Structured attempt metadata + // carries it beside opaque credential references, so an in-flight create in + // this runtime still returns the normal duplicate error while a later + // runtime can recognize and retry a row stranded before its write ran. + const credentialWriteRuntimeId = crypto.randomUUID(); const staticToolOwner = (): Owner => (subject == null ? "org" : "user"); const staticToolConnection = (integration: StaticIntegrationDecl): ConnectionName => @@ -2341,9 +2421,22 @@ export const createExecutor = - Effect.fail(new StorageError({ message: enterpriseManagedMessage(cause), cause })), + Effect.fail( + new StorageError({ + message: enterpriseManagedMessage(cause), + cause, + }), + ), }), Effect.tapError((error) => Predicate.isTagged(error, "CredentialResolutionError") && error.reauthRequired === true @@ -2435,7 +2533,10 @@ export const createExecutor = new CredentialResolutionError({ owner, @@ -2456,7 +2557,9 @@ export const createExecutor = Effect.annotateCurrentSpan({ "executor.oauth.refresh.outcome": "ok" })), + Effect.tap(() => + Effect.annotateCurrentSpan({ + "executor.oauth.refresh.outcome": "ok", + }), + ), Effect.tapError((error: StorageFailure | CredentialResolutionError) => Effect.annotateCurrentSpan({ "executor.oauth.refresh.outcome": "fail", @@ -2845,7 +2965,14 @@ export const createExecutor = = {}; for (const [variable, itemId] of Object.entries(connectionItemIds(row))) { - out[variable] = yield* provider.get(ProviderItemId.make(itemId)); + const value = yield* provider.get(ProviderItemId.make(itemId)); + if (value === null && parseCredentialWriteAttempt(row.credential_write) !== null) { + return yield* new CredentialWriteIncompleteError({ + message: `Credential write for ${row.owner}/${row.integration}/${row.name} is incomplete; retry the connection operation.`, + cause: undefined, + }); + } + out[variable] = value; } return out; }).pipe( @@ -2948,18 +3075,40 @@ export const createExecutor = { - const runtime = runtimes.get(row.plugin_id); - const describe = runtime?.plugin.describeAuthMethods; - if (!describe) return []; - const record = rowToIntegrationRecord(row); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read - try { - return describe(record); - } catch { - return []; - } - }; + const warnedInvalidAuthMethods = new Set(); + const describeAuthMethodsForRow = ( + row: IntegrationRow, + ): Effect.Effect => + Effect.gen(function* () { + const runtime = runtimes.get(row.plugin_id); + const describe = runtime?.plugin.describeAuthMethods; + if (!describe) return []; + const record = rowToIntegrationRecord(row); + const methods = yield* Effect.sync(() => describe(record)).pipe( + // A malformed plugin projector must never fail the catalog read. + Effect.catchCause(() => Effect.succeed([])), + ); + const valid: AuthMethodDescriptor[] = []; + for (const method of methods) { + if (method.kind === "none" && method.placements !== undefined) { + const warningKey = [row.plugin_id, row.slug, method.id] + .map((value) => `${value.length}:${value}`) + .join(""); + if (!warnedInvalidAuthMethods.has(warningKey)) { + warnedInvalidAuthMethods.add(warningKey); + yield* Effect.logWarning("executor omitted invalid plugin auth method", { + plugin: row.plugin_id, + integration: row.slug, + method: method.id, + reason: "no-auth methods cannot declare credential placements", + }); + } + continue; + } + valid.push(method); + } + return valid; + }); const describeDisplayForRow = (row: IntegrationRow): IntegrationDisplayDescriptor => { const runtime = runtimes.get(row.plugin_id); @@ -3005,8 +3154,12 @@ export const createExecutor = - rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)), + const dbIntegrations = yield* Effect.forEach(rows, (row) => + describeAuthMethodsForRow(row).pipe( + Effect.map((authMethods) => + rowToIntegration(row, authMethods, describeDisplayForRow(row)), + ), + ), ); // A scoped toolkit must not advertise providers it grants no tools from // (mirrors `connectionsList`). Static integrations are system namespaces, not @@ -3034,19 +3187,19 @@ export const createExecutor = => - findIntegrationRow(slug).pipe( - Effect.map((row) => - row ? rowToIntegrationRecord(row, describeAuthMethodsForRow(row)) : null, - ), - ); + Effect.gen(function* () { + const row = yield* findIntegrationRow(slug); + if (!row) return null; + return rowToIntegrationRecord(row, yield* describeAuthMethodsForRow(row)); + }); // Best-effort post-commit notification for `ExecutorConfig.onIntegrationChange`. // Routed through `afterCommit` so the observer sees only DURABLE changes: @@ -3060,13 +3213,18 @@ export const createExecutor = => + ): Effect.Effect => transaction( Effect.gen(function* () { const now = new Date(); const existing = yield* findIntegrationRow(input.slug); const config = input.config === undefined ? null : input.config; if (existing) { + // Extension methods also run for subjectless boot/system executors, + // which must be able to converge existing catalog rows. A bound + // subject is an end-user principal, so its replacement is the same + // workspace mutation as creation and requires the live role guard. + if (subject !== null) yield* guardOrgWrite(); yield* core.updateMany("integration", { where: (b: AnyCb) => b("slug", "=", String(input.slug)), set: { @@ -3081,6 +3239,9 @@ export const createExecutor = created - ? notifyIntegrationChange({ kind: "added", pluginKey: pluginId, slug: input.slug }) + ? notifyIntegrationChange({ + kind: "added", + pluginKey: pluginId, + slug: input.slug, + }) : Effect.void, ), Effect.asVoid, @@ -3111,30 +3276,33 @@ export const createExecutor = => - Effect.gen(function* () { - const now = new Date(); - const set: Record = { updated_at: now }; - if (patch.name !== undefined) set.name = patch.name; - if (patch.description !== undefined) set.description = patch.description; - if (patch.config !== undefined) { - set.config = patch.config; - // A config change can change the derived tools. The writer can only - // rebuild catalogs in its own partition (owner policy), so revise - // the integration: other subjects' connections compare this stamp - // against their `tools_synced_at` and lazily rebuild on next read. - set.config_revised_at = now.getTime(); - } - yield* core.updateMany("integration", { - where: (b: AnyCb) => b("slug", "=", String(slug)), - set, - }); - }); + ): Effect.Effect => + transaction( + Effect.gen(function* () { + yield* guardOrgWrite(); + const now = new Date(); + const set: Record = { updated_at: now }; + if (patch.name !== undefined) set.name = patch.name; + if (patch.description !== undefined) set.description = patch.description; + if (patch.config !== undefined) { + set.config = patch.config; + // A config change can change the derived tools. The writer can only + // rebuild catalogs in its own partition (owner policy), so revise + // the integration: other subjects' connections compare this stamp + // against their `tools_synced_at` and lazily rebuild on next read. + set.config_revised_at = now.getTime(); + } + yield* core.updateMany("integration", { + where: (b: AnyCb) => b("slug", "=", String(slug)), + set, + }); + }), + ); const integrationsUpdatePublic = ( slug: IntegrationSlug, patch: { readonly name?: string; readonly description?: string }, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { const existing = yield* findIntegrationRow(slug); if (!existing) return yield* new IntegrationNotFoundError({ slug }); @@ -3143,9 +3311,13 @@ export const createExecutor = => + ): Effect.Effect< + void, + IntegrationRemovalNotAllowedError | OrgWriteDeniedError | StorageFailure + > => transaction( Effect.gen(function* () { + yield* guardOrgWrite(); const existing = yield* findIntegrationRow(slug); if (!existing) return null; if (!existing.can_remove) { @@ -3153,10 +3325,11 @@ export const createExecutor = @@ -3177,7 +3350,11 @@ export const createExecutor = removedPluginId !== null - ? notifyIntegrationChange({ kind: "removed", pluginKey: removedPluginId, slug }) + ? notifyIntegrationChange({ + kind: "removed", + pluginKey: removedPluginId, + slug, + }) : Effect.void, ), Effect.asVoid, @@ -3220,7 +3397,7 @@ export const createExecutor = => - Effect.gen(function* () { - const row = yield* findIntegrationRow(slug); - if (!row) return yield* new IntegrationNotFoundError({ slug }); - yield* core.updateMany("integration", { - where: (b: AnyCb) => b("slug", "=", String(slug)), - set: { health_check: spec, updated_at: new Date() }, - }); - }); + ): Effect.Effect => + transaction( + Effect.gen(function* () { + yield* guardOrgWrite(); + const row = yield* findIntegrationRow(slug); + if (!row) return yield* new IntegrationNotFoundError({ slug }); + yield* core.updateMany("integration", { + where: (b: AnyCb) => b("slug", "=", String(slug)), + set: { health_check: spec, updated_at: new Date() }, + }); + }), + ); // ------------------------------------------------------------------ // Per-connection tool production @@ -3303,7 +3483,11 @@ export const createExecutor = { const health = row ? Option.getOrNull(decodeLastHealth(row.last_health)) : null; return isToolSyncHealth(health) - ? { tools_synced_at: Date.now(), last_health: null, updated_at: new Date() } + ? { + tools_synced_at: Date.now(), + last_health: null, + updated_at: new Date(), + } : { tools_synced_at: Date.now() }; }; // Every exit stamps the sync time — including the cleanup paths that @@ -3563,9 +3747,11 @@ export const createExecutor = => Effect.gen(function* () { + yield* guardOrgWrite(input.owner); const name = connectionIdentifier(String(input.name)); // Typed (not StorageError) so the HTTP edge can answer 400 with the // reason instead of an opaque 500 — callers can act on it. @@ -3592,7 +3778,36 @@ export const createExecutor = 0 && + duplicateAttempt !== null && + duplicateAttempt.runtimeId !== credentialWriteRuntimeId; + const hasMissingCredential = + belongsToCrashedRuntime && duplicateProvider?.set + ? (yield* Effect.forEach(duplicateItemIds, (itemId) => + duplicateProvider + .get(ProviderItemId.make(itemId)) + .pipe(Effect.map((value) => value === null)), + )).some(Boolean) + : false; + retryingRowId = hasMissingCredential ? storageRowId(duplicate) : null; + retryingItemIds = hasMissingCredential ? duplicateItemIds : []; + if (retryingRowId !== null) { + yield* Effect.logInfo("executor credential stranded row detected", { + tenant, + owner: input.owner, + integration: String(input.integration), + rowId: retryingRowId, + }); + } + } + if (duplicate && retryingRowId === null) { return yield* new ConnectionAlreadyExistsError({ owner: input.owner, integration: input.integration, @@ -3605,7 +3820,23 @@ export const createExecutor = method.template === String(input.template), + ); + const isNoAuth = selectedAuthMethod?.kind === "none"; + const suppliedInputs = normalizeConnectionInputs(input); + if ( + isNoAuth && + suppliedInputs.some( + ({ origin }) => "from" in origin || ("value" in origin && origin.value.length > 0), + ) + ) { + return yield* new InvalidConnectionInputError({ + message: "A no-auth connection cannot accept credential inputs.", + }); + } + const inputs = isNoAuth ? [] : suppliedInputs; const pasted = inputs.filter((i) => "value" in i.origin); const external = inputs.filter((i) => "from" in i.origin); // A credentialed connection is born wired: it must reference at least @@ -3613,28 +3844,25 @@ export const createExecutor = | null = null; const itemIds: Record = {}; // Pasted-value provider writes, built here but run only AFTER this // create wins the row insert below. Each entry carries its own undo // so a write that does not complete can tear down exactly the items // it already stored. - const pastedWrites: Array<{ - readonly itemId: ProviderItemId; - readonly write: Effect.Effect; - readonly remove: Effect.Effect | null; - }> = []; + const pastedWrites: CredentialWriteSnapshot[] = []; if (external.length > 0 && pasted.length > 0) { return yield* new InvalidConnectionInputError({ message: "A connection cannot mix pasted and external-provider inputs.", @@ -3668,22 +3896,40 @@ export const createExecutor = = []; for (const i of pasted) { - const itemId = `connection:${input.owner}:${input.integration}:${name}:${i.variable}`; - // Deferred until the row insert wins: the item id is deterministic, - // so writing here would overwrite the credential of an existing (or - // concurrently created) connection with the same name even when - // this create loses the row conflict. + const itemId = credentialAttemptItemId( + `connection:${input.owner}:${input.integration}:${name}:${i.variable}`, + attemptId, + ); + // Deferred until the row insert wins. The row records this attempt's + // unique item id before the provider write, so an in-flight read can + // only observe a missing value and fail closed; it cannot fall back + // to a predecessor's credential. if ("value" in i.origin && provider.set) { const id = ProviderItemId.make(itemId); - pastedWrites.push({ - itemId: id, - write: provider.set(id, i.origin.value), - remove: provider.delete ? provider.delete(id) : null, - }); + credentialValues.push({ itemId: id, value: i.origin.value }); } itemIds[i.variable] = itemId; } + if (provider.set) { + pastedWrites.push( + ...(yield* snapshotCredentialWrites( + { ...provider, set: provider.set }, + credentialValues, + (itemId) => + new StorageError({ + message: `Credential provider ${String(provider.key)} cannot restore new credential ${String(itemId)} because it does not support deletion.`, + cause: undefined, + }), + )), + ); + } } const keys = yield* Effect.try({ @@ -3699,10 +3945,6 @@ export const createExecutor = { - const value = row == null ? null : (row as Record)["row_id"]; - return typeof value === "string" ? value : null; - }; const insertedRowId = yield* transaction( Effect.gen(function* () { const existing = yield* findConnectionRow({ @@ -3711,10 +3953,15 @@ export const createExecutor = b("row_id", "=", retryingRowId), }); } const inserted = yield* core.create("connection", { @@ -3726,6 +3973,7 @@ export const createExecutor = 0) { - const written: ProviderItemId[] = []; - const writeAll = Effect.gen(function* () { - for (const entry of pastedWrites) { - yield* entry.write; - written.push(entry.itemId); - } - }); - - // While the committed row exists no concurrent create can win, so - // on an incomplete write it is ours to tear down — a surviving row - // whose item_ids were never stored would 409 every retry while - // failing every invocation with `connection_value_missing`. But - // "ours" needs proof before anything is deleted: the composite key - // (owner, integration, name) can change hands while compensation is - // still pending (provider calls can be slow) — a concurrent remove - // frees the name, a new create takes it and writes fresh secrets at - // the SAME deterministic item ids. The one column that tells our - // row apart from such a successor is `insertedRowId`, so the row - // delete carries it in its WHERE (guarded delete), and the identity - // check runs in the same transaction as the delete so both see one - // consistent row. - // - // Order matters: the ROW is deleted first, and the credential items - // are undone only when the guarded delete actually removed OUR row. - // If the row is already gone or replaced, losing compensation is - // correct — the remover already cleaned up, and the deterministic - // item ids may by now carry the successor's secrets, so deleting - // them here would clobber a healthy connection. Nothing here is - // silent: every failed or impossible undo is logged, and - // `rowOutcome` converts a stranded row into an error that names it. - // - // Known limitations, accepted deliberately: provider credential - // stores expose no conditional delete, so perfect cleanup under a - // concurrent remove/recreate is impossible at this layer, and no - // further machinery is built for it. - // - Under concurrent remove/recreate, compensation may skip item - // deletion, leaving orphaned credential values at the - // deterministic item ids. Orphans are inert without a row and the - // next same-shaped create overwrites them; orphans are preferred - // over the alternative, clobbering a live successor's secrets. - // - A successor that overwrites one variable, fails before the - // next, and then also fails its own compensating row delete - // leaves a stranded connection that can resolve one stale - // predecessor value. Closing this needs provider-side conditional - // deletes, which do not exist; the stranded state is surfaced - // loudly as the typed StorageError below, naming the connection. - // - On a non-transactional adapter (statements auto-commit, no - // rollback — Cloudflare D1) the guarded delete may already have - // committed when its own rejection surfaces or when the - // confirmation read fails; the items are left in place as inert - // orphans. - const rowOutcomeRef = yield* Ref.make< - "removed" | "superseded" | "overtaken" | "failed" | "unknown" - >("removed"); - const logContext = { + if (retryingRowId !== null) { + yield* Effect.logInfo("executor credential stranded row replaced", { + tenant, owner: input.owner, integration: String(input.integration), - connection: String(name), - }; - const compensate = Effect.gen(function* () { - // Progress marker for the transaction below. It distinguishes - // "compensation failed before the guarded delete was issued" - // (nothing can have been deleted; a surviving row is truthfully - // stranded) from "the delete was attempted". Set BEFORE the - // delete statement is issued, not after it resolves: a rejection - // DURING the statement is already ambiguous on an auto-commit - // adapter (D1), where the delete may have executed before the - // rejection surfaced. Deliberately a plain mutable outside the - // transaction: a rollback cannot un-set it, which is the point — - // it records that the statement was issued, not committed state. - // On an interactive adapter a failure from the attempt onward - // rolls the delete back; on an auto-commit adapter the delete - // may already have committed. This layer cannot tell which world - // it is in, so any failure from the attempt onward is reported - // as "unknown", never as a stranded row. - let rowDeleteAttempted = false; - const rowOutcome = yield* transaction( - Effect.gen(function* () { - const current = yield* findConnectionRow({ - owner: input.owner, - integration: input.integration, - name, - }); - if (rowIdOf(current) !== insertedRowId) { - return "superseded" as const; - } - // From here on a failure can no longer prove the row - // survived: the statement below may execute before its - // rejection surfaces. - rowDeleteAttempted = true; - yield* core.deleteMany("connection", { - where: (b: AnyCb) => - b.and( - byOwner(input.owner)(b), - b("integration", "=", String(input.integration)), - b("name", "=", String(name)), - // Even if the row changed hands between the read above - // and this statement, only OUR row can match. - b("row_id", "=", insertedRowId), - ), - }); - // `deleteMany` returns void, so whether the guarded delete - // removed OUR row cannot be read off its result — and the - // identity read above and the delete can straddle a - // concurrent remove/recreate under weak isolation. Confirm - // against the table instead, in this same transaction: the - // guarded delete could only ever match our row, so any row - // still holding the name is a successor (or restored - // original) — our delete removed nothing, and the surviving - // row's owner owns both the name and the credential items. - // Only when no row remains is ours provably gone and the - // items ours to undo. A successor inserting after this - // transaction commits can still interleave with the item - // deletes below; that residual is accepted (see the - // known-limitations note above). - const survivor = yield* findConnectionRow({ - owner: input.owner, - integration: input.integration, - name, - }); - if (survivor !== null) { - return "overtaken" as const; - } - return "removed" as const; - }), - ).pipe( - Effect.catchCause((cause) => - rowDeleteAttempted - ? Effect.logError( - "executor connection create could not confirm its compensating row delete: the connection row may be deleted or stranded", - { ...logContext, cause }, - ).pipe(Effect.as("unknown" as const)) - : Effect.logError( - "executor connection create stranded a connection row it could not delete", - { ...logContext, cause }, - ).pipe(Effect.as("failed" as const)), + replacedRowId: retryingRowId, + replacementRowId: insertedRowId, + }); + } + + if (retryingRowId !== null && retryingItemIds.length > 0) { + const provider = credentialProviders.get(providerKey); + const deleteItem = provider?.delete; + if (deleteItem) { + yield* afterCommit( + Effect.forEach(retryingItemIds, (itemId) => deleteItem(ProviderItemId.make(itemId)), { + discard: true, + }).pipe( + Effect.catch(() => + Effect.logWarning("executor credential replaced row cleanup failed", { + tenant, + owner: input.owner, + integration: String(input.integration), + replacedRowId: retryingRowId, + }), + ), ), ); - yield* Ref.set(rowOutcomeRef, rowOutcome); - if (rowOutcome === "superseded") { - // A concurrent remove took our row, and a successor may already - // own the name and the item ids. The remover cleaned up; - // nothing left here is ours to touch. - yield* Effect.logInfo( - "executor connection create skipped compensation: the connection row was already removed or replaced", - logContext, - ); - return; - } - if (rowOutcome === "overtaken") { - // The guarded delete removed nothing and another row now holds - // the name: a concurrent remove/recreate interleaved between - // the identity read and the delete. The surviving row's owner - // owns the name and the credential items; deleting the items - // here would destroy that live connection's secrets. - yield* Effect.logInfo( - "executor connection create skipped credential cleanup: its guarded row delete removed nothing and another connection now holds the name; the surviving connection owns the credential items", - logContext, - ); - return; - } - if (rowOutcome === "failed") { - // Compensation failed before the row delete was even issued, - // so the row — still ours — keeps holding the name together - // with the items that already landed. Leave the items in - // place (they belong to the - // stranded row the caller is told to remove) and let the exit - // handling below surface the error. - return; - } - if (rowOutcome === "unknown") { - // The guarded delete was attempted but its outcome could not - // be confirmed — the statement itself rejected, or the - // confirmation read after it failed — so whether OUR row - // survived cannot be known: an interactive adapter rolled the - // delete back with the transaction (row stranded), a - // non-transactional adapter may have already committed it (row - // gone). Deleting the items under a surviving row - // would strand it valueless, so ALL item deletion is skipped; - // the exit handling below reports the unconfirmed state. - return; - } - for (const entry of pastedWrites) { - if (!written.includes(entry.itemId)) continue; - if (entry.remove === null) { - // A provider exposing `set` without `delete` cannot undo its - // own writes; say so instead of silently skipping. - yield* Effect.logWarning( - "executor connection create cannot undo a credential write: the provider has no delete, so a partial credential may be stranded", - { ...logContext, item: String(entry.itemId) }, - ); - continue; - } - yield* entry.remove.pipe( - Effect.catchCause((cause) => - Effect.logError("executor connection create failed to undo a credential write", { - ...logContext, - item: String(entry.itemId), - cause, + } + } + + const ref: ConnectionRef = { + owner: input.owner, + integration: input.integration, + name, + }; + + // Provider writes cannot enlist in the database transaction. Queue + // them on the outermost commit so a plugin wrapping this create in + // `ctx.transaction` cannot roll the row back after credentials have + // escaped. Tool production stays in the same required finalizer, + // after the credentials it may need for authenticated introspection. + // Without an enclosing transaction the inner row transaction has + // already committed, so the finalizer runs here and preserves the + // ordinary call's synchronous success/failure contract. + yield* afterCommitRequired( + Effect.gen(function* () { + if (pastedWrites.length > 0) { + const written: ProviderItemId[] = []; + const writeAll = Effect.gen(function* () { + for (const entry of pastedWrites) { + yield* entry.write; + written.push(entry.itemId); + } + }); + + // Every item id belongs only to this attempt, so a successor can + // never resolve or be clobbered through these entries. Row identity + // still matters for compensation: delete only the row this attempt + // inserted, then best-effort delete its now-inert item ids. On a + // non-transactional adapter a rejected delete can have an unknown + // outcome; in that case the items remain because a surviving row + // may still reference them. Missing attempt references remain + // fail-closed and a later executor incarnation can retry them. + const rowOutcomeRef = yield* Ref.make< + "removed" | "superseded" | "overtaken" | "failed" | "unknown" + >("removed"); + const logContext = { + owner: input.owner, + integration: String(input.integration), + connection: String(name), + }; + const compensate = Effect.gen(function* () { + // Progress marker for the transaction below. It distinguishes + // "compensation failed before the guarded delete was issued" + // (nothing can have been deleted; a surviving row is truthfully + // stranded) from "the delete was attempted". Set BEFORE the + // delete statement is issued, not after it resolves: a rejection + // DURING the statement is already ambiguous on an auto-commit + // adapter (D1), where the delete may have executed before the + // rejection surfaced. Deliberately a plain mutable outside the + // transaction: a rollback cannot un-set it, which is the point — + // it records that the statement was issued, not committed state. + // On an interactive adapter a failure from the attempt onward + // rolls the delete back; on an auto-commit adapter the delete + // may already have committed. This layer cannot tell which world + // it is in, so any failure from the attempt onward is reported + // as "unknown", never as a stranded row. + let rowDeleteAttempted = false; + const rowOutcome = yield* transaction( + Effect.gen(function* () { + const current = yield* findConnectionRow({ + owner: input.owner, + integration: input.integration, + name, + }); + if (storageRowId(current) !== insertedRowId) { + return "superseded" as const; + } + // From here on a failure can no longer prove the row + // survived: the statement below may execute before its + // rejection surfaces. + rowDeleteAttempted = true; + yield* core.deleteMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(input.owner)(b), + b("integration", "=", String(input.integration)), + b("name", "=", String(name)), + // Even if the row changed hands between the read above + // and this statement, only OUR row can match. + b("row_id", "=", insertedRowId), + ), + }); + // `deleteMany` returns void, so whether the guarded delete + // removed OUR row cannot be read off its result — and the + // identity read above and the delete can straddle a + // concurrent remove/recreate under weak isolation. Confirm + // against the table instead, in this same transaction: the + // guarded delete could only ever match our row, so any row + // still holding the name is a successor (or restored + // original) — our delete removed nothing, and the surviving + // row's owner owns both the name and the credential items. + // Only when no row remains is ours provably gone and the + // items ours to undo. A successor inserting after this + // transaction commits can still interleave with the item + // deletes below; that residual is accepted (see the + // known-limitations note above). + const survivor = yield* findConnectionRow({ + owner: input.owner, + integration: input.integration, + name, + }); + if (survivor !== null) { + return "overtaken" as const; + } + return "removed" as const; }), - ), + ).pipe( + Effect.catchCause((cause) => + rowDeleteAttempted + ? Effect.logError( + "executor connection create could not confirm its compensating row delete: the connection row may be deleted or stranded", + { ...logContext, cause }, + ).pipe(Effect.as("unknown" as const)) + : Effect.logError( + "executor connection create stranded a connection row it could not delete", + { ...logContext, cause }, + ).pipe(Effect.as("failed" as const)), + ), + ); + yield* Ref.set(rowOutcomeRef, rowOutcome); + if (rowOutcome === "superseded") { + // A concurrent remove took our row, and a successor may already + // own the name and the item ids. The remover cleaned up; + // nothing left here is ours to touch. + yield* Effect.logInfo( + "executor connection create skipped compensation: the connection row was already removed or replaced", + logContext, + ); + return; + } + if (rowOutcome === "overtaken") { + // The guarded delete removed nothing and another row now holds + // the name: a concurrent remove/recreate interleaved between + // the identity read and the delete. The surviving row's owner + // owns the name and the credential items; deleting the items + // here would destroy that live connection's secrets. + yield* Effect.logInfo( + "executor connection create skipped credential cleanup: its guarded row delete removed nothing and another connection now holds the name; the surviving connection owns the credential items", + logContext, + ); + return; + } + if (rowOutcome === "failed") { + // Compensation failed before the row delete was even issued, + // so the row — still ours — keeps holding the name together + // with the items that already landed. Leave the items in + // place (they belong to the + // stranded row the caller is told to remove) and let the exit + // handling below surface the error. + return; + } + if (rowOutcome === "unknown") { + // The guarded delete was attempted but its outcome could not + // be confirmed — the statement itself rejected, or the + // confirmation read after it failed — so whether OUR row + // survived cannot be known: an interactive adapter rolled the + // delete back with the transaction (row stranded), a + // non-transactional adapter may have already committed it (row + // gone). Deleting the items under a surviving row + // would strand it valueless, so ALL item deletion is skipped; + // the exit handling below reports the unconfirmed state. + return; + } + const writtenSnapshots = pastedWrites.filter((entry) => + written.includes(entry.itemId), + ); + for (const entry of writtenSnapshots) { + if (!entry.restoreSupported) { + yield* Effect.logWarning( + "executor connection create cannot undo a credential write: the provider has no delete, so a partial credential may be stranded", + { ...logContext, item: String(entry.itemId) }, + ); + } + } + const restoreOutcome = yield* restoreCredentialSnapshotsWithRecheck( + writtenSnapshots, + Effect.succeed(true), + ); + if (Predicate.isTagged(restoreOutcome, "Failed")) { + yield* Effect.logError( + "executor connection create failed to restore credential writes", + { ...logContext, cause: restoreOutcome.cause }, + ); + } + }); + + // `onExit`, not `tapError`: compensation must also run when the + // write is interrupted or dies with a defect. The stranded-row + // promise must hold on every one of those exit shapes, so the exit + // is captured and re-raised by hand: a typed failure or a defect + // that left the row behind becomes the StorageError below, while an + // interruption cannot carry a typed error at all (interrupting wins + // over failing) — for it the loud log inside `compensate` is the + // only signal, and the interruption is re-raised untouched. + const writeExit = yield* writeAll.pipe( + Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : compensate)), + Effect.exit, ); + if (Exit.isFailure(writeExit)) { + const rowOutcome = yield* Ref.get(rowOutcomeRef); + if (rowOutcome === "failed" && !Cause.hasInterruptsOnly(writeExit.cause)) { + return yield* new StorageError({ + message: `Failed to store credentials for connection ${input.owner}/${String(input.integration)}/${String(name)}, and the compensating delete also failed: the connection row is stranded with incomplete credentials and must be removed manually.`, + cause: Cause.squash(writeExit.cause), + }); + } + if (rowOutcome === "unknown" && !Cause.hasInterruptsOnly(writeExit.cause)) { + return yield* new StorageError({ + message: `Failed to store credentials for connection ${input.owner}/${String(input.integration)}/${String(name)}, and its compensating delete could not be confirmed: the connection row may be deleted or may remain with incomplete credentials; its credential items were left in place.`, + cause: Cause.squash(writeExit.cause), + }); + } + return yield* Effect.failCause(writeExit.cause); + } } - }); - // `onExit`, not `tapError`: compensation must also run when the - // write is interrupted or dies with a defect. The stranded-row - // promise must hold on every one of those exit shapes, so the exit - // is captured and re-raised by hand: a typed failure or a defect - // that left the row behind becomes the StorageError below, while an - // interruption cannot carry a typed error at all (interrupting wins - // over failing) — for it the loud log inside `compensate` is the - // only signal, and the interruption is re-raised untouched. - const writeExit = yield* writeAll.pipe( - Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : compensate)), - Effect.exit, - ); - if (Exit.isFailure(writeExit)) { - const rowOutcome = yield* Ref.get(rowOutcomeRef); - if (rowOutcome === "failed" && !Cause.hasInterruptsOnly(writeExit.cause)) { - return yield* new StorageError({ - message: `Failed to store credentials for connection ${input.owner}/${String(input.integration)}/${String(name)}, and the compensating delete also failed: the connection row is stranded with incomplete credentials and must be removed manually.`, - cause: Cause.squash(writeExit.cause), + const committedRow = yield* findConnectionRow(ref); + if (storageRowId(committedRow) !== insertedRowId) { + yield* Effect.logInfo("executor credential write superseded", { + tenant, + owner: input.owner, + integration: String(input.integration), + rowId: insertedRowId, }); - } - if (rowOutcome === "unknown" && !Cause.hasInterruptsOnly(writeExit.cause)) { + const provider = credentialProviders.get(providerKey); + const deleteItem = provider?.delete; + if (deleteItem) { + yield* Effect.forEach( + pastedWrites, + (entry) => deleteItem(entry.itemId).pipe(Effect.ignore), + { discard: true }, + ); + } return yield* new StorageError({ - message: `Failed to store credentials for connection ${input.owner}/${String(input.integration)}/${String(name)}, and its compensating delete could not be confirmed: the connection row may be deleted or may remain with incomplete credentials; its credential items were left in place.`, - cause: Cause.squash(writeExit.cause), + message: `Credential write attempt for ${input.owner}/${String(input.integration)}/${String(name)} was superseded before it became ready.`, + cause: undefined, }); } - return yield* Effect.failCause(writeExit.cause); - } - } - // Record the sighting. The request seam (`makeScopedExecutor`) already - // does this for every hosted call, so this is the belt for direct - // SDK/CLI callers that never pass through it — a connecting principal - // must always have a subject row. Outside - // the transaction above: bookkeeping must not roll back the - // connection, and `touchSubject` cannot fail. No-ops on a pure-org - // executor (no principal to record), including for `owner: "org"` - // connections created by a bound member. - yield* touchSubject(rootDbUntyped, { tenant, externalId: subject }); - - const ref: ConnectionRef = { - owner: input.owner, - integration: input.integration, - name, - }; - // Produce + persist tools for the new connection. - yield* produceConnectionTools(integrationRow, ref).pipe( - Effect.catchTag("IntegrationNotFoundError", () => Effect.succeed([] as readonly Tool[])), + // Record the sighting. The request seam (`makeScopedExecutor`) already + // does this for every hosted call, so this is the belt for direct + // SDK/CLI callers that never pass through it — a connecting principal + // must always have a subject row. Outside the committed connection + // transaction: bookkeeping cannot roll back the connection, and + // `touchSubject` cannot fail. No-ops on a pure-org executor. + yield* touchSubject(rootDbUntyped, { tenant, externalId: subject }); + + // Produce + persist tools only after credentials exist. + yield* produceConnectionTools(integrationRow, ref).pipe( + Effect.catchTag("IntegrationNotFoundError", () => + Effect.succeed([] as readonly Tool[]), + ), + ); + }), ); const row = yield* findConnectionRow(ref); @@ -4049,6 +4309,7 @@ export const createExecutor = => @@ -4104,14 +4365,71 @@ export const createExecutor = ({ + baseItemId: entry.itemId, + value: entry.value, + itemId: credentialAttemptItemId(entry.itemId, credentialAttemptId), + })); + const versionedItemId = versionedCredentialValues.find( + (entry) => entry.baseItemId === input.itemId, + )?.itemId; + if (versionedItemId === undefined) { + return yield* new StorageError({ + message: "OAuth mint input did not include the access-token credential value.", + cause: undefined, + }); + } + const versionedRefreshItemId = + input.refreshItemId === null + ? null + : versionedCredentialValues.find((entry) => entry.baseItemId === input.refreshItemId) + ?.itemId; + if (input.refreshItemId !== null && versionedRefreshItemId === undefined) { + return yield* new StorageError({ + message: "OAuth mint input did not include the refresh credential value.", + cause: undefined, + }); + } + const credentialWrites = yield* snapshotCredentialWrites( + { ...credentialProvider, set: credentialSet }, + versionedCredentialValues.map((entry) => ({ + itemId: ProviderItemId.make(entry.itemId), + value: entry.value, + })), + () => + new StorageError({ + message: `Credential provider ${input.provider} cannot safely compensate a new OAuth credential because it does not support deletion.`, + cause: undefined, + }), + { requireDeleteForNew: true }, + ); + const mintedRowIdOf = (row: unknown): string | null => { + const value = row == null ? null : (row as Record)["row_id"]; + return typeof value === "string" ? value : null; + }; + const committed = yield* transaction( Effect.gen(function* () { const existing = yield* findConnectionRow(ref); const existingLabel = existing?.identity_label?.trim() ? existing.identity_label : null; @@ -4120,11 +4438,12 @@ export const createExecutor = = { template: String(input.template), provider: input.provider, - item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, + item_ids: { [PRIMARY_INPUT_VARIABLE]: versionedItemId }, + credential_write: credentialWrite, identity_label: identityLabel, oauth_client: String(input.oauthClient), oauth_client_owner: input.oauthClientOwner, - refresh_item_id: input.refreshItemId, + refresh_item_id: versionedRefreshItemId, expires_at: input.expiresAt, oauth_scope: input.oauthScope, oauth_token_url: input.oauthTokenUrl ?? null, @@ -4137,47 +4456,157 @@ export const createExecutor = - b.and( - byOwner(input.owner)(b), - b("integration", "=", String(input.integration)), - b("name", "=", String(name)), - ), - set, + const existingRowId = mintedRowIdOf(existing); + if (existingRowId === null) { + return yield* new StorageError({ + message: + "Storage adapter did not return the existing connection row's row_id; OAuth replacement cannot be compensated safely.", + cause: undefined, + }); + } + yield* core.deleteMany("connection", { + where: (b: AnyCb) => b("row_id", "=", existingRowId), }); - } else { - yield* core.create("connection", { - tenant: keys.tenant, - owner: keys.owner, - subject: keys.subject, - integration: String(input.integration), - name: String(name), - template: String(input.template), - provider: input.provider, - item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, - identity_label: identityLabel, - // Curated description: never stamped by a mint — a reconnect - // or token refresh must not erase what the user wrote. - description: null, - oauth_client: String(input.oauthClient), - oauth_client_owner: input.oauthClientOwner, - refresh_item_id: input.refreshItemId, - expires_at: input.expiresAt, - oauth_scope: input.oauthScope, - oauth_token_url: input.oauthTokenUrl ?? null, - provider_state: providerState, - created_at: now, - updated_at: now, + } + const inserted = yield* core.create( + "connection", + existing + ? (() => { + const { row_id: _previousRowId, ...previous } = existing as ConnectionRow & { + readonly row_id: string; + }; + return { ...previous, ...set }; + })() + : { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + integration: String(input.integration), + name: String(name), + template: String(input.template), + provider: input.provider, + item_ids: { [PRIMARY_INPUT_VARIABLE]: versionedItemId }, + credential_write: credentialWrite, + identity_label: identityLabel, + // Curated description: never stamped by a mint — a reconnect + // or token refresh must not erase what the user wrote. + description: null, + oauth_client: String(input.oauthClient), + oauth_client_owner: input.oauthClientOwner, + refresh_item_id: versionedRefreshItemId, + expires_at: input.expiresAt, + oauth_scope: input.oauthScope, + oauth_token_url: input.oauthTokenUrl ?? null, + provider_state: providerState, + created_at: now, + updated_at: now, + }, + ); + const rowId = mintedRowIdOf(inserted); + if (rowId === null) { + return yield* new StorageError({ + message: + "Storage adapter did not return the minted connection row's row_id; credential persistence cannot be compensated safely.", + cause: undefined, }); } + return { existing, rowId }; }), ); - // Produce + persist tools for the minted connection (same path - // connections.create uses). - yield* produceConnectionTools(integrationRow, ref).pipe( - Effect.catchTag("IntegrationNotFoundError", () => Effect.succeed([] as readonly Tool[])), + yield* afterCommitRequired( + Effect.gen(function* () { + const credentialWriteExit = yield* Effect.forEach( + credentialWrites, + (entry) => credentialSet(entry.itemId, entry.value), + { discard: true }, + ).pipe(Effect.exit); + if (Exit.isFailure(credentialWriteExit)) { + const restoredRow = yield* transaction( + Effect.gen(function* () { + const current = yield* findConnectionRow(ref); + if (mintedRowIdOf(current) !== committed.rowId) return false; + yield* core.deleteMany("connection", { + where: (b: AnyCb) => b("row_id", "=", committed.rowId), + }); + if (committed.existing) { + yield* core.create("connection", committed.existing); + } + return true; + }), + ).pipe( + Effect.catchCause((cause) => + Effect.logError( + "OAuth connection credential compensation could not restore its row", + { + owner: input.owner, + integration: String(input.integration), + connection: String(name), + cause, + }, + ).pipe(Effect.as(false)), + ), + ); + if (!restoredRow) { + return yield* new StorageError({ + message: `Failed to store OAuth credentials for ${input.owner}/${String(input.integration)}/${String(name)}, and the connection row could not be safely restored.`, + cause: credentialWriteExit.cause, + }); + } + + // The row recheck preserves the compensation contract, but the + // provider items themselves are unique to this attempt. Restoring + // or deleting them therefore cannot touch a successor even if one + // commits after the recheck. + const credentialRestore = yield* restoreCredentialSnapshotsWithRecheck( + credentialWrites, + transaction( + Effect.gen(function* () { + const current = yield* findConnectionRow(ref); + return committed.existing === null + ? current === null + : mintedRowIdOf(current) === mintedRowIdOf(committed.existing); + }), + ), + ); + if (Predicate.isTagged(credentialRestore, "Superseded")) { + return yield* new StorageError({ + message: `Failed to store OAuth credentials for ${input.owner}/${String(input.integration)}/${String(name)}, and credential cleanup was skipped because the compensated row was superseded.`, + cause: credentialWriteExit.cause, + }); + } + if (Predicate.isTagged(credentialRestore, "Failed")) { + return yield* new StorageError({ + message: `Failed to store OAuth credentials for ${input.owner}/${String(input.integration)}/${String(name)}, and credential compensation also failed.`, + cause: credentialRestore.cause, + }); + } + return yield* Effect.failCause(credentialWriteExit.cause); + } + + const deleteCredential = credentialProvider.delete; + if (committed.existing && deleteCredential) { + const priorIds = new Set([ + ...Object.values(connectionItemIds(committed.existing)), + ...(committed.existing.refresh_item_id === null + ? [] + : [String(committed.existing.refresh_item_id)]), + ]); + yield* Effect.forEach( + priorIds, + (itemId) => deleteCredential(ProviderItemId.make(itemId)).pipe(Effect.ignore), + { discard: true }, + ); + } + + // Produce + persist tools for the minted connection (same path + // connections.create uses). + yield* produceConnectionTools(integrationRow, ref).pipe( + Effect.catchTag("IntegrationNotFoundError", () => + Effect.succeed([] as readonly Tool[]), + ), + ); + }), ); const row = yield* findConnectionRow(ref); @@ -4191,12 +4620,13 @@ export const createExecutor = => - Effect.gen(function* () { - const row = yield* findConnectionRow(ref); - if (!row) { - return yield* new ConnectionNotFoundError({ - owner: ref.owner, - integration: ref.integration, - name: ref.name, + ): Effect.Effect => + transaction( + Effect.gen(function* () { + yield* guardOrgWrite(ref.owner); + const row = yield* findConnectionRow(ref); + if (!row) { + return yield* new ConnectionNotFoundError({ + owner: ref.owner, + integration: ref.integration, + name: ref.name, + }); + } + const set: Record = { updated_at: new Date() }; + if (input.description !== undefined) set.description = input.description; + if (input.identityLabel !== undefined) set.identity_label = input.identityLabel; + yield* core.updateMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(ref.owner)(b), + b("integration", "=", String(ref.integration)), + b("name", "=", String(ref.name)), + ), + set, }); - } - const set: Record = { updated_at: new Date() }; - if (input.description !== undefined) set.description = input.description; - if (input.identityLabel !== undefined) set.identity_label = input.identityLabel; - yield* core.updateMany("connection", { - where: (b: AnyCb) => - b.and( - byOwner(ref.owner)(b), - b("integration", "=", String(ref.integration)), - b("name", "=", String(ref.name)), - ), - set, - }); - const updated = yield* findConnectionRow(ref); - return rowToConnection(updated ?? row); - }); + const updated = yield* findConnectionRow(ref); + return rowToConnection(updated ?? row); + }), + ); const connectionsRemove = ( ref: ConnectionRef, - ): Effect.Effect => + ): Effect.Effect => transaction( Effect.gen(function* () { + yield* guardOrgWrite(ref.owner); const row = yield* findConnectionRow(ref); if (!row) { return yield* new ConnectionNotFoundError({ @@ -4319,9 +4753,10 @@ export const createExecutor = => Effect.gen(function* () { + yield* guardOrgWrite(ref.owner); const row = yield* findConnectionRow(ref); if (!row) { return yield* new ConnectionNotFoundError({ @@ -4339,7 +4774,10 @@ export const createExecutor = ({ status: "unknown", checkedAt: Date.now() }); + const unknownHealth = (): HealthCheckResult => ({ + status: "unknown", + checkedAt: Date.now(), + }); /** Persist a verdict with a compare-and-swap on `updated_at`: the single * UPDATE commits only while the row still carries the stamp the caller's @@ -4698,14 +5136,17 @@ export const createExecutor = persistProbeHealthResult(ref, result)), - Effect.map((result) => ({ source: "credential_only" as const, result })), + Effect.map((result) => ({ + source: "credential_only" as const, + result, + })), ) : foldCredentialResolutionIntoVerdict( Effect.gen(function* () { const values = yield* resolveConnectionValues(connectionRow); const record = rowToIntegrationRecord( integrationRow, - describeAuthMethodsForRow(integrationRow), + yield* describeAuthMethodsForRow(integrationRow), ); const grantedScopes = grantedScopesFromRow(connectionRow); const credential: ToolInvocationCredential = { @@ -4722,7 +5163,12 @@ export const createExecutor = persistProbeHealthResult(ref, result)), - Effect.map((result) => ({ source: "probe" as const, result })), + Effect.map((result) => ({ + source: "probe" as const, + result, + })), ); const run = freshVerdict.pipe( Effect.exit, @@ -4763,7 +5212,9 @@ export const createExecutor = ({ kind: "prepared" as const, resolve }))) + ? activeToolPolicyProvider.prepare().pipe( + Effect.map((resolve) => ({ + kind: "prepared" as const, + resolve, + })), + ) : activeToolPolicyProvider.resolve ? Effect.succeed({ kind: "provider" as const, @@ -5067,7 +5521,9 @@ export const createExecutor = => - Effect.gen(function* () { - if (!isValidPattern(input.pattern)) { - return yield* new StorageError({ - message: `Invalid tool policy pattern: ${input.pattern}`, - cause: undefined, + ): Effect.Effect => + transaction( + Effect.gen(function* () { + yield* guardOrgWrite(input.owner); + if (!isValidPattern(input.pattern)) { + return yield* new StorageError({ + message: `Invalid tool policy pattern: ${input.pattern}`, + cause: undefined, + }); + } + if (!isToolPolicyAction(input.action)) { + return yield* new StorageError({ + message: `Invalid tool policy action: ${String(input.action)}`, + cause: undefined, + }); + } + yield* requireUserSubject(input.owner); + const keys = yield* Effect.try({ + try: () => ownedKeys(input.owner), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), }); - } - if (!isToolPolicyAction(input.action)) { - return yield* new StorageError({ - message: `Invalid tool policy action: ${String(input.action)}`, - cause: undefined, + const existing = yield* core.findMany("tool_policy", { + where: byOwner(input.owner), }); - } - yield* requireUserSubject(input.owner); - const keys = yield* Effect.try({ - try: () => ownedKeys(input.owner), - catch: (cause) => storageFailureFromUnknown("invalid owner", cause), - }); - const existing = yield* core.findMany("tool_policy", { - where: byOwner(input.owner), - }); - // Default placement is specificity-aware (below any more-specific - // rule), not top-of-list: a client that omits position — the UI when - // its policy list is stale, the API, an agent tool — must not have its - // broad rule silently shadow an existing narrow one. - const position = input.position ?? positionForNewPattern(input.pattern, existing); - const id = PolicyId.make( - `pol_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, - ); - const now = new Date(); - const created = yield* core.create("tool_policy", { - tenant: keys.tenant, - owner: keys.owner, - subject: keys.subject, - id: String(id), - pattern: input.pattern, - action: input.action, - position, - created_at: now, - updated_at: now, - }); - return rowToToolPolicy(created); - }); + // Default placement is specificity-aware (below any more-specific + // rule), not top-of-list: a client that omits position — the UI when + // its policy list is stale, the API, an agent tool — must not have its + // broad rule silently shadow an existing narrow one. + const position = input.position ?? positionForNewPattern(input.pattern, existing); + const id = PolicyId.make( + `pol_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, + ); + const now = new Date(); + const created = yield* core.create("tool_policy", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: String(id), + pattern: input.pattern, + action: input.action, + position, + created_at: now, + updated_at: now, + }); + return rowToToolPolicy(created); + }), + ); const policiesUpdate = ( input: UpdateToolPolicyInput, - ): Effect.Effect => - Effect.gen(function* () { - if (input.pattern !== undefined && !isValidPattern(input.pattern)) { - return yield* new StorageError({ - message: `Invalid tool policy pattern: ${input.pattern}`, - cause: undefined, - }); - } - const where = (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)); - const existing = yield* core.findFirst("tool_policy", { where }); - if (!existing) { - return yield* new StorageError({ - message: `Tool policy not found: ${input.id}`, - cause: undefined, - }); - } - const set: Record = { updated_at: new Date() }; - if (input.pattern !== undefined) set.pattern = input.pattern; - if (input.action !== undefined) set.action = input.action; - if (input.position !== undefined) set.position = input.position; - yield* core.updateMany("tool_policy", { where, set }); - const updated = yield* core.findFirst("tool_policy", { where }); - return rowToToolPolicy(updated ?? ({ ...existing, ...set } as ToolPolicyRow)); - }); + ): Effect.Effect => + transaction( + Effect.gen(function* () { + yield* guardOrgWrite(input.owner); + if (input.pattern !== undefined && !isValidPattern(input.pattern)) { + return yield* new StorageError({ + message: `Invalid tool policy pattern: ${input.pattern}`, + cause: undefined, + }); + } + const where = (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)); + const existing = yield* core.findFirst("tool_policy", { where }); + if (!existing) { + return yield* new StorageError({ + message: `Tool policy not found: ${input.id}`, + cause: undefined, + }); + } + const set: Record = { updated_at: new Date() }; + if (input.pattern !== undefined) set.pattern = input.pattern; + if (input.action !== undefined) set.action = input.action; + if (input.position !== undefined) set.position = input.position; + yield* core.updateMany("tool_policy", { where, set }); + const updated = yield* core.findFirst("tool_policy", { where }); + if (!updated) { + return yield* new StorageError({ + message: `Tool policy disappeared while it was being updated: ${input.id}`, + cause: undefined, + }); + } + return rowToToolPolicy(updated); + }), + ); - const policiesRemove = (input: RemoveToolPolicyInput): Effect.Effect => - core.deleteMany("tool_policy", { - where: (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)), - }); + const policiesRemove = ( + input: RemoveToolPolicyInput, + ): Effect.Effect => + transaction( + Effect.gen(function* () { + yield* guardOrgWrite(input.owner); + const where = (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)); + yield* core.deleteMany("tool_policy", { where }); + }), + ); const policiesResolve = ( address: ToolAddress, @@ -5531,7 +6005,9 @@ export const createExecutor = => Effect.gen(function* () { - const row = yield* core.findFirst("artifact", { where: artifactById(id) }); + const row = yield* core.findFirst("artifact", { + where: artifactById(id), + }); if (!row) return yield* new ArtifactNotFoundError({ id: ArtifactId.make(id) }); return rowToArtifact(row); }); @@ -5549,7 +6025,9 @@ export const createExecutor = Effect.succeed(null)), ); if (!refreshed) return { result: first, usedValues: values }; - yield* Effect.annotateCurrentSpan({ "executor.oauth.refresh.retried": true }); - return { result: yield* invokeWith(refreshed), usedValues: refreshed }; + yield* Effect.annotateCurrentSpan({ + "executor.oauth.refresh.retried": true, + }); + return { + result: yield* invokeWith(refreshed), + usedValues: refreshed, + }; }); yield* healPersistedHealthOnUse(connectionRow, result, usedValues); return result; @@ -6109,7 +6599,9 @@ export const createExecutor = ownedKeys(owner), + guardOrgWrite: (owner: Owner) => guardOrgWrite(owner), defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), connectionNameTaken: (ref) => findConnectionRow(ref).pipe(Effect.map((row) => row !== null)), @@ -6121,23 +6613,25 @@ export const createExecutor = - findIntegrationRow(integration).pipe( - Effect.map((row): OAuthScopePolicy => { - const methods = row ? describeAuthMethodsForRow(row) : []; - const selected = - methods.find((m: AuthMethodDescriptor) => m.template === String(template)) ?? - (methods.length === 1 ? methods[0] : undefined); - const oauth = selected?.kind === "oauth" ? selected.oauth : undefined; - // Declared scopes win. Discover only when the selected method - // declares none but names a source to discover them from (MCP). - // The discovery URL rides along so `oauth.start` can discover - // scopes even for a client whose RFC 8707 resource was cleared. - if (oauth?.scopes === undefined && oauth?.discoveryUrl !== undefined) { - return { kind: "discover", discoveryUrl: oauth.discoveryUrl }; - } - return { kind: "scopes", scopes: oauth?.scopes ?? [] }; - }), - ), + Effect.gen(function* () { + const row = yield* findIntegrationRow(integration); + const methods = row ? yield* describeAuthMethodsForRow(row) : []; + const selected = + methods.find((m: AuthMethodDescriptor) => m.template === String(template)) ?? + (methods.length === 1 ? methods[0] : undefined); + const oauth = selected?.kind === "oauth" ? selected.oauth : undefined; + // Declared scopes win. Discover only when the selected method + // declares none but names a source to discover them from (MCP). + // The discovery URL rides along so `oauth.start` can discover + // scopes even for a client whose RFC 8707 resource was cleared. + if (oauth?.scopes === undefined && oauth?.discoveryUrl !== undefined) { + return { + kind: "discover", + discoveryUrl: oauth.discoveryUrl, + } satisfies OAuthScopePolicy; + } + return { kind: "scopes", scopes: oauth?.scopes ?? [] } satisfies OAuthScopePolicy; + }), httpClientLayer: config.httpClientLayer, fetch: config.fetch, endpointUrlPolicy: config.oauthEndpointUrlPolicy, @@ -6201,6 +6695,7 @@ export const createExecutor = guardOrgWrite(), register: (input: RegisterIntegrationInput) => integrationsRegister(plugin.id, input), update: (slug, patch) => integrationsUpdate(slug, patch), list: () => integrationsList(), @@ -6403,7 +6898,9 @@ export const createExecutor = => platformCore - .findFirst("subject", { where: (b: AnyCb) => b("external_id", "=", externalId) }) + .findFirst("subject", { + where: (b: AnyCb) => b("external_id", "=", externalId), + }) .pipe(Effect.map((row) => (row === null ? null : rowToAdminSubject(row)))); const listSubjectConnections = ( diff --git a/packages/core/sdk/src/fuma-runtime.ts b/packages/core/sdk/src/fuma-runtime.ts index 19852d48d3..ef8adf79c6 100644 --- a/packages/core/sdk/src/fuma-runtime.ts +++ b/packages/core/sdk/src/fuma-runtime.ts @@ -7,6 +7,18 @@ export class StorageError extends Data.TaggedError("StorageError")<{ readonly cause: unknown; }> {} +/** + * A committed row points at an executor-owned credential write that did not + * finish. The operation is safe to retry; provider references and causes stay + * internal and are never projected onto the wire. + */ +export class CredentialWriteIncompleteError extends Data.TaggedError( + "CredentialWriteIncompleteError", +)<{ + readonly message: string; + readonly cause: unknown; +}> {} + export class UniqueViolationError extends Data.TaggedError("UniqueViolationError")<{ readonly model?: string; }> {} @@ -41,7 +53,11 @@ export class StorageConnectionError extends Data.TaggedError("StorageConnectionE readonly cause: unknown; }> {} -export type StorageFailure = StorageError | StorageConnectionError | UniqueViolationError; +export type StorageFailure = + | StorageError + | CredentialWriteIncompleteError + | StorageConnectionError + | UniqueViolationError; export type FumaTables = Record; type EmptyFumaSchema = FumaSchema<"latest", Record>; @@ -175,6 +191,7 @@ const stableMessage = (label: string, code: string | undefined): string => export const isStorageFailure = (error: unknown): error is StorageFailure => Predicate.isTagged(error, "StorageError") || + Predicate.isTagged(error, "CredentialWriteIncompleteError") || Predicate.isTagged(error, "StorageConnectionError") || Predicate.isTagged(error, "UniqueViolationError"); @@ -217,9 +234,17 @@ export const activeFumaDbRef = Context.Reference("executor/Active // still roll back. `afterCommit` solves this structurally: while a transaction // is active the effect is queued on the outermost transaction's hook list and // runs after its commit; with no active transaction it runs immediately. -// Hooks are best-effort observers: failures and defects are swallowed, and a -// rolled-back transaction discards its queue. -const pendingCommitHooksRef = Context.Reference> | null>( +// A rolled-back transaction discards both kinds. Observer failures are +// swallowed; required finalizers attempt every queued effect and report their +// combined failure only after the database commit is already durable. +type PendingCommitHook = + | { readonly _tag: "Observer"; readonly effect: Effect.Effect } + | { + readonly _tag: "Required"; + readonly effect: Effect.Effect; + }; + +const pendingCommitHooksRef = Context.Reference | null>( "executor/PendingCommitHooks", { defaultValue: () => null }, ); @@ -228,11 +253,41 @@ export const afterCommit = (effect: Effect.Effect): Effect.Effect => Effect.flatMap(Effect.service(pendingCommitHooksRef), (hooks) => hooks ? Effect.sync(() => { - hooks.push(effect); + hooks.push({ _tag: "Observer", effect }); }) : effect.pipe(Effect.ignoreCause({ log: false })), ); +/** + * Run a required external finalizer after the outermost database commit. + * Nested transactions queue it and return; the outer transaction attempts all + * required finalizers after commit and then reports any combined failure. + */ +export const afterCommitRequired = ( + effect: Effect.Effect, +): Effect.Effect => + Effect.flatMap(Effect.service(pendingCommitHooksRef), (hooks) => + hooks + ? Effect.sync(() => { + hooks.push({ _tag: "Required", effect }); + }) + : effect, + ); + +const runCommitHooks = (hooks: readonly PendingCommitHook[]): Effect.Effect => + Effect.gen(function* () { + let failureCause: Cause.Cause = Cause.empty; + for (const hook of hooks) { + if (Predicate.isTagged(hook, "Observer")) { + yield* hook.effect.pipe(Effect.ignoreCause({ log: false })); + continue; + } + const exit = yield* Effect.exit(hook.effect); + if (Exit.isFailure(exit)) failureCause = Cause.combine(failureCause, exit.cause); + } + if (failureCause.reasons.length > 0) return yield* Effect.failCause(failureCause); + }); + class TransactionEffectFailure { constructor(readonly error: unknown) {} } @@ -304,37 +359,33 @@ export const makeFumaClient = (db: FumaDb, options: MakeFumaClientOptions = {}): // The outermost transaction owns the post-commit hook queue; hooks // queued anywhere inside (including nested pass-through transactions) // run only after THIS commit, and are discarded on rollback. - const commitHooks: Array> = []; - return Effect.tryPromise({ - try: () => - db.transaction(async (transactionDb) => { - const exit = await Effect.runPromiseExit( - effect.pipe( - Effect.provideService(activeFumaDbRef, transactionDb), - Effect.provideService(pendingCommitHooksRef, commitHooks), - ), - ); - if (Exit.isSuccess(exit)) return exit.value; - - const failure = exit.cause.reasons.find(Cause.isFailReason); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB transactions roll back when the callback rejects - if (failure) throw new TransactionEffectFailure(failure.error); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB transactions roll back when the callback rejects - throw new TransactionEffectDefect(exit.cause); - }), - catch: (cause): E | StorageFailure => { - if (cause instanceof TransactionEffectFailure) return cause.error as E; - if (cause instanceof TransactionEffectDefect) { - return fumaFailureFromCause("transaction", cause.cause); - } - return fumaFailureFromCause("transaction", cause); - }, - }).pipe( - Effect.tap(() => - Effect.forEach(commitHooks, (hook) => hook.pipe(Effect.ignoreCause({ log: false })), { - discard: true, - }), - ), + const commitHooks: PendingCommitHook[] = []; + return Effect.contextWith((context) => + Effect.tryPromise({ + try: () => + db.transaction(async (transactionDb) => { + const exit = await Effect.runPromiseExitWith(context)( + effect.pipe( + Effect.provideService(activeFumaDbRef, transactionDb), + Effect.provideService(pendingCommitHooksRef, commitHooks), + ), + ); + if (Exit.isSuccess(exit)) return exit.value; + + const failure = exit.cause.reasons.find(Cause.isFailReason); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB transactions roll back when the callback rejects + if (failure) throw new TransactionEffectFailure(failure.error); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB transactions roll back when the callback rejects + throw new TransactionEffectDefect(exit.cause); + }), + catch: (cause): E | StorageFailure => { + if (cause instanceof TransactionEffectFailure) return cause.error as E; + if (cause instanceof TransactionEffectDefect) { + return fumaFailureFromCause("transaction", cause.cause); + } + return fumaFailureFromCause("transaction", cause); + }, + }).pipe(Effect.tap(() => runCommitHooks(commitHooks))), ); }).pipe(Effect.withSpan("fumadb.transaction")) as Effect.Effect; diff --git a/packages/core/sdk/src/http-auth/authoring.test.ts b/packages/core/sdk/src/http-auth/authoring.test.ts index 3a5a22f0eb..31f339588a 100644 --- a/packages/core/sdk/src/http-auth/authoring.test.ts +++ b/packages/core/sdk/src/http-auth/authoring.test.ts @@ -94,4 +94,16 @@ describe("request-shaped authoring", () => { ), ).toBe(true); }); + + it('rejects the reserved no-auth slug "none" for an API-key method', () => { + const result = Schema.decodeUnknownExit(ApiKeyAuthTemplate)({ + slug: "none", + type: "apiKey", + headers: { Authorization: [variable("token")] }, + }); + + expect(String(result)).toContain( + 'The auth template slug "none" is reserved for no-auth methods', + ); + }); }); diff --git a/packages/core/sdk/src/http-auth/authoring.ts b/packages/core/sdk/src/http-auth/authoring.ts index 43bff68eb8..0238320667 100644 --- a/packages/core/sdk/src/http-auth/authoring.ts +++ b/packages/core/sdk/src/http-auth/authoring.ts @@ -24,6 +24,7 @@ import { Schema } from "effect"; +import { NO_AUTH_TEMPLATE } from "../ids"; import { TOKEN_VARIABLE, type ApiKeyAuthMethod, type AuthPlacement } from "./auth-method"; export interface AuthTemplateVariable { @@ -69,7 +70,13 @@ export const ApiKeyAuthTemplate = Schema.Struct({ label: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, AuthTemplateValue)), queryParams: Schema.optional(Schema.Record(Schema.String, AuthTemplateValue)), -}); +}).check( + Schema.makeFilter((template) => + template.slug?.trim() === String(NO_AUTH_TEMPLATE) + ? `The auth template slug "${String(NO_AUTH_TEMPLATE)}" is reserved for no-auth methods` + : undefined, + ), +); export type ApiKeyAuthTemplate = typeof ApiKeyAuthTemplate.Type; const placementFromValue = ( diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 95fe72e8f3..d8ac973134 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -37,6 +37,7 @@ export type { StorageFailure, } from "./fuma-runtime"; export { + CredentialWriteIncompleteError, StorageError, StorageConnectionError, UniqueViolationError, @@ -74,6 +75,7 @@ export { IntegrationNotFoundError, IntegrationAlreadyExistsError, IntegrationRemovalNotAllowedError, + OrgWriteDeniedError, ConnectionAlreadyExistsError, ConnectionNotFoundError, CredentialProviderNotRegisteredError, @@ -106,7 +108,6 @@ export type { ValidateConnectionInput, } from "./connection"; export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; - // Credential providers. export type { CredentialProvider, ProviderEntry } from "./provider"; @@ -439,6 +440,13 @@ export { connectionAddress, toolAddress, } from "./executor"; +export { + CurrentOrgWriteAccess, + currentOrgWriteAccess, + makeOrgWriteAccessState, + type OrgWriteAccess, + type OrgWriteAccessState, +} from "./org-write-access"; // CLI / runtime config. export { diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index d141a21540..355a141aee 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -2,7 +2,7 @@ import type { Effect } from "effect"; import { Schema } from "effect"; import type { Connection } from "./connection"; -import type { UserActionableError } from "./errors"; +import type { OrgWriteDeniedError, UserActionableError } from "./errors"; import type { StorageFailure } from "./fuma-runtime"; import { type AuthTemplateSlug, @@ -501,12 +501,15 @@ export class OAuthSessionNotFoundError extends Schema.TaggedErrorClass Effect.Effect; + ) => Effect.Effect; /** Mint a client via RFC 7591 Dynamic Client Registration (no pre-shared * client id/secret) and persist it as an owner-scoped `oauth_client`. */ readonly registerDynamicClient: ( input: RegisterDynamicClientInput, - ) => Effect.Effect; + ) => Effect.Effect< + OAuthClientSlug, + OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure + >; /** All registered clients visible to the caller (their org's shared clients + * their own user clients), as metadata-only summaries — never the secret. */ readonly listClients: () => Effect.Effect; @@ -518,13 +521,16 @@ export interface OAuthService { readonly removeClient: ( owner: Owner, slug: OAuthClientSlug, - ) => Effect.Effect; + ) => Effect.Effect; readonly start: ( input: OAuthStartInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly complete: ( input: OAuthCompleteInput, - ) => Effect.Effect; + ) => Effect.Effect< + Connection, + OAuthCompleteError | OAuthSessionNotFoundError | OrgWriteDeniedError | StorageFailure + >; readonly cancel: (state: OAuthState) => Effect.Effect; readonly probe: ( input: OAuthProbeInput, diff --git a/packages/core/sdk/src/oauth-register-dynamic.test.ts b/packages/core/sdk/src/oauth-register-dynamic.test.ts index f52f5b9934..6a5a281407 100644 --- a/packages/core/sdk/src/oauth-register-dynamic.test.ts +++ b/packages/core/sdk/src/oauth-register-dynamic.test.ts @@ -58,6 +58,38 @@ const oauthPlugin = definePlugin(() => ({ const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; describe("oauth.registerDynamicClient", () => { + it.effect("denies member org DCR before contacting the authorization server", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + orgWrites: "denied", + }); + + const error = yield* executor.oauth + .registerDynamicClient({ + owner: "org", + slug: CLIENT, + issuer: server.issuerUrl, + registrationEndpoint: server.registrationEndpoint, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + resource: server.mcpResourceUrl, + scopes: ["read"], + tokenEndpointAuthMethodsSupported: ["none"], + clientName: "Denied DCR", + redirectUri: FLOW_REDIRECT_URI, + originIntegration: INTEG, + }) + .pipe(Effect.flip); + + expect(Predicate.isTagged("OrgWriteDeniedError")(error)).toBe(true); + expect(registerRequestCount(yield* server.requests)).toBe(0); + }), + ), + ); + it.effect("DCR mints + persists a public (no-secret) client that lists + connects", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-remove-client.test.ts b/packages/core/sdk/src/oauth-remove-client.test.ts index 1239895afe..94580671f8 100644 --- a/packages/core/sdk/src/oauth-remove-client.test.ts +++ b/packages/core/sdk/src/oauth-remove-client.test.ts @@ -282,10 +282,10 @@ const txPlugin = (store: Map) => }), }))(); -// The client secret is keyed by (owner, slug) ALONE — the key outlives the row -// it belonged to, which is what makes the deferred delete a claim that has to be -// re-checked rather than replayed. -const SECRET_ITEM = "oauth-client:user:acme-user:secret"; +// Client-secret references are versioned per registration attempt; tests read +// the current terminal `:secret` slot without assuming its attempt id. +const secretValue = (store: ReadonlyMap): string | undefined => + [...store.entries()].find(([itemId]) => itemId.endsWith(":secret"))?.[1]; /** The registration used by the secret-lifecycle tests below, parameterised only * by the secret so each one can prove which incarnation's secret survived. */ @@ -309,7 +309,7 @@ describe("removing a client defers the secret deletion to the outermost commit", plugins: [txPlugin(store)] as const, }); yield* executor.oauth.createClient(userClient("user-secret")); - expect(store.get(SECRET_ITEM)).toBe("user-secret"); + expect(secretValue(store)).toBe("user-secret"); // A caller wraps the removal in its own transaction, then fails. const outcome = yield* Effect.exit( @@ -326,7 +326,7 @@ describe("removing a client defers the secret deletion to the outermost commit", const after = yield* executor.oauth.listClients(); expect(after.map((client) => String(client.slug))).toContain(String(USER_CLIENT)); // ...so its secret must still be there, or it can never authenticate again. - expect(store.get(SECRET_ITEM)).toBe("user-secret"); + expect(secretValue(store)).toBe("user-secret"); }), ), ); @@ -342,12 +342,12 @@ describe("removing a client defers the secret deletion to the outermost commit", plugins: [txPlugin(store)] as const, }); yield* executor.oauth.createClient(userClient("user-secret")); - expect(store.get(SECRET_ITEM)).toBe("user-secret"); + expect(secretValue(store)).toBe("user-secret"); yield* executor.demo.inTransaction(executor.oauth.removeClient("user", USER_CLIENT)); expect(yield* executor.oauth.listClients()).toEqual([]); - expect(store.has(SECRET_ITEM)).toBe(false); + expect(secretValue(store)).toBeUndefined(); }), ), ); @@ -363,11 +363,11 @@ describe("removing a client defers the secret deletion to the outermost commit", plugins: [txPlugin(store)] as const, }); yield* executor.oauth.createClient(userClient("user-secret")); - expect(store.get(SECRET_ITEM)).toBe("user-secret"); + expect(secretValue(store)).toBe("user-secret"); yield* executor.oauth.removeClient("user", USER_CLIENT); - expect(store.has(SECRET_ITEM)).toBe(false); + expect(secretValue(store)).toBeUndefined(); }), ), ); @@ -391,7 +391,7 @@ describe("removing a client defers the secret deletion to the outermost commit", dataDir, }); yield* a.executor.oauth.createClient(userClient("a-secret")); - expect(store.get(SECRET_ITEM)).toBe("a-secret"); + expect(secretValue(store)).toBe("a-secret"); const b = yield* makeTestWorkspaceHarness({ plugins, @@ -405,7 +405,7 @@ describe("removing a client defers the secret deletion to the outermost commit", const clientsA = yield* a.executor.oauth.listClients(); expect(clientsA.map((client) => String(client.slug))).toContain(String(USER_CLIENT)); // ...and so must its secret, which B never owned. - expect(store.get(SECRET_ITEM)).toBe("a-secret"); + expect(secretValue(store)).toBe("a-secret"); }), ), ); @@ -470,7 +470,7 @@ describe("removing a client does not delete a recreated client's secret", () => clientId: "first-client", clientSecret: "first-secret", }); - expect(store.get(SECRET_ITEM)).toBe("first-secret"); + expect(secretValue(store)).toBe("first-secret"); // The race, made deterministic: the removal and the re-registration of // the same slug commit together, so the deferred delete runs against a @@ -493,7 +493,7 @@ describe("removing a client does not delete a recreated client's secret", () => // The new incarnation is listed, and its secret survived... const after = yield* executor.oauth.listClients(); expect(after.map((client) => String(client.slug))).toContain(String(USER_CLIENT)); - expect(store.get(SECRET_ITEM)).toBe("second-secret"); + expect([...store.values()]).toContain("second-secret"); // ...and still authenticates: the server refuses a token request that // presents the wrong secret or none, so a connection can only be minted diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 484d5cdadd..802a7342af 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -14,13 +14,25 @@ // redeems the session, exchanges the code, and mints the connection. // --------------------------------------------------------------------------- -import { Duration, Effect, Layer, Match, Option, Predicate, Schema } from "effect"; +import { Duration, Effect, Exit, Layer, Match, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { connectionIdentifier } from "./connection-name-identifier"; import type { Connection } from "./connection"; +import type { OrgWriteDeniedError } from "./errors"; import type { IFumaClient, StorageFailure } from "./fuma-runtime"; -import { afterCommit, StorageError } from "./fuma-runtime"; +import { + afterCommit, + afterCommitRequired, + CredentialWriteIncompleteError, + StorageError, +} from "./fuma-runtime"; +import { + credentialAttemptItemId, + makeCredentialWriteAttempt, + parseCredentialWriteAttempt, + type CredentialWriteAttempt, +} from "./credential-item-reference"; import { AuthTemplateSlug, ConnectionName, @@ -59,6 +71,11 @@ import { } from "./oauth-client"; import type { OwnerBinding } from "./plugin"; import type { CredentialProvider } from "./provider"; +import { + restoreCredentialSnapshotsWithRecheck, + snapshotCredentialWrites, + type CredentialWriteSnapshot, +} from "./credential-compensation"; import { discoverAuthorizationServerMetadata, discoverProtectedResourceMetadata, @@ -111,6 +128,12 @@ export interface MintOAuthConnectionInput { /** Credential provider key + item id the access token is stored under. */ readonly provider: string; readonly itemId: string; + /** Credential material to persist only after the row transaction commits. + * Values remain internal to the executor/provider boundary. */ + readonly credentialValues: readonly { + readonly itemId: string; + readonly value: string; + }[]; readonly oauthClient: OAuthClientSlug; /** The owner of `oauthClient` (persisted so refresh loads it by explicit owner). */ readonly oauthClientOwner: Owner; @@ -188,11 +211,17 @@ export interface OAuthServiceDeps { readonly owner: OwnerBinding; readonly tenant: string; readonly subject: string | null; + /** Executor incarnation recorded beside attempt-owned provider references. */ + readonly credentialWriteRuntimeId: string; readonly ownedKeys: (owner: Owner) => { readonly tenant: string; readonly owner: Owner; readonly subject: string; }; + /** Workspace-settings gate from the executor binding + * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the + * user-intent client/connect surfaces. */ + readonly guardOrgWrite: (owner: Owner) => Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ readonly mintOAuthConnection: ( @@ -847,7 +876,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const createClient = ( input: CreateOAuthClientInput, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { // The `first-party:` namespace is reserved for config-declared apps — a // stored row under it would be shadowed by (or worse, impersonate) the @@ -858,6 +887,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + yield* deps.guardOrgWrite(input.owner); yield* validateClientEndpoints(input, deps.endpointUrlPolicy); if ( input.tokenEndpointAuthMethod !== undefined && @@ -879,10 +909,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); const now = new Date(); - // Store the secret out-of-band in the default writable provider; the row - // keeps only its item id. A public/PKCE client (empty secret) stores null - // — there is no plaintext column to fall back to (the schema dropped it). + // Resolve the out-of-band write up front, but do not mutate the provider + // until the database transaction commits. let clientSecretItemIdValue: string | null = null; + let credentialWrite: CredentialWriteAttempt | null = null; + let secretWrite: CredentialWriteSnapshot | undefined; if (input.clientSecret.length > 0) { const provider = deps.defaultWritableProvider(); if (!provider || !provider.set) { @@ -892,49 +923,192 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } - clientSecretItemIdValue = clientSecretItemId(input.owner, input.slug); - yield* provider.set(ProviderItemId.make(clientSecretItemIdValue), input.clientSecret); + const attemptId = crypto.randomUUID(); + credentialWrite = makeCredentialWriteAttempt(deps.credentialWriteRuntimeId, attemptId); + clientSecretItemIdValue = credentialAttemptItemId( + clientSecretItemId(input.owner, input.slug), + attemptId, + ); + const itemId = ProviderItemId.make(clientSecretItemIdValue); + const [snapshot] = yield* snapshotCredentialWrites( + { ...provider, set: provider.set }, + [{ itemId, value: input.clientSecret }], + () => + new StorageError({ + message: + "The default credential provider cannot safely create an OAuth client secret because it does not support compensating deletion.", + cause: undefined, + }), + { requireDeleteForNew: true }, + ); + secretWrite = snapshot; } - yield* deps.fuma - .use("oauth_client.deleteExisting", (db) => - looseDb(db).deleteMany("oauth_client", { - where: (b: any) => - b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), - }), - ) - .pipe(Effect.catch(() => Effect.void)); - yield* deps.fuma.use("oauth_client.create", (db) => - looseDb(db).create("oauth_client", { - tenant: keys.tenant, - owner: keys.owner, - subject: keys.subject, - slug: String(input.slug), - authorization_url: input.authorizationUrl, - token_url: input.tokenUrl, - grant: input.grant, - client_id: input.clientId, - client_secret_item_id: clientSecretItemIdValue, - token_endpoint_auth_method: input.tokenEndpointAuthMethod ?? null, - resource: input.resource ?? null, - origin_kind: input.origin?.kind ?? "manual", - // Recorded intent, kept for BOTH origins: a manual app registered from - // an integration's dialog stamps its integration so the picker can - // match it exactly, the same way a DCR client records the integration - // that requested it. - origin_integration: - input.origin?.integration == null ? null : String(input.origin.integration), - origin_issuer: - input.origin?.kind === "dynamic_client_registration" - ? (canonicalIssuerUrl(input.originIssuer) ?? null) - : null, - origin_redirect_uri: - input.origin?.kind === "dynamic_client_registration" - ? (input.originRedirectUri ?? null) - : null, - created_at: now, + const committed = yield* deps.fuma.transaction( + Effect.gen(function* () { + const existing = yield* deps.fuma.use("oauth_client.findExisting", (db) => + looseDb(db).findFirst("oauth_client", { + where: (b: any) => + b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), + }), + ); + yield* deps.fuma + .use("oauth_client.deleteExisting", (db) => + looseDb(db).deleteMany("oauth_client", { + where: (b: any) => + b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), + }), + ) + .pipe(Effect.catch(() => Effect.void)); + const inserted = yield* deps.fuma.use("oauth_client.create", (db) => + looseDb(db).create("oauth_client", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + slug: String(input.slug), + authorization_url: input.authorizationUrl, + token_url: input.tokenUrl, + grant: input.grant, + client_id: input.clientId, + client_secret_item_id: clientSecretItemIdValue, + credential_write: credentialWrite, + token_endpoint_auth_method: input.tokenEndpointAuthMethod ?? null, + resource: input.resource ?? null, + origin_kind: input.origin?.kind ?? "manual", + // Recorded intent, kept for BOTH origins: a manual app registered from + // an integration's dialog stamps its integration so the picker can + // match it exactly, the same way a DCR client records the integration + // that requested it. + origin_integration: + input.origin?.integration == null ? null : String(input.origin.integration), + origin_issuer: + input.origin?.kind === "dynamic_client_registration" + ? (canonicalIssuerUrl(input.originIssuer) ?? null) + : null, + origin_redirect_uri: + input.origin?.kind === "dynamic_client_registration" + ? (input.originRedirectUri ?? null) + : null, + created_at: now, + }), + ); + const rowId = (inserted as Record)["row_id"]; + if (typeof rowId !== "string") { + return yield* new StorageError({ + message: + "Storage adapter did not return the inserted OAuth client row's row_id; the credential write cannot be compensated safely.", + cause: undefined, + }); + } + return { existing, rowId }; }), ); + + if (secretWrite) { + yield* afterCommitRequired( + Effect.gen(function* () { + const writeExit = yield* secretWrite.write.pipe(Effect.exit); + if (Exit.isFailure(writeExit)) { + // Restore the row only while it is still the exact row this call + // inserted. This attempt owns its provider item id, so cleanup can + // never overwrite a concurrent successor's secret. + const restoredRow = yield* deps.fuma + .transaction( + Effect.gen(function* () { + const current = yield* deps.fuma.use("oauth_client.compensate.find", (db) => + looseDb(db).findFirst("oauth_client", { + where: (b: any) => + b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), + }), + ); + if ( + (current as Record | null)?.["row_id"] !== committed.rowId + ) { + return false; + } + yield* deps.fuma.use("oauth_client.compensate.delete", (db) => + looseDb(db).deleteMany("oauth_client", { + where: (b: any) => b("row_id", "=", committed.rowId), + }), + ); + if (committed.existing) { + const existing = committed.existing; + yield* deps.fuma.use("oauth_client.compensate.restore", (db) => + looseDb(db).create("oauth_client", existing), + ); + } + return true; + }), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logError( + "OAuth client credential compensation could not restore its row", + { + owner: input.owner, + client: String(input.slug), + cause, + }, + ).pipe(Effect.as(false)), + ), + ); + if (!restoredRow) { + return yield* new StorageError({ + message: `Failed to store the OAuth client secret for ${input.owner}/${String(input.slug)}, and the client row could not be safely restored.`, + cause: writeExit.cause, + }); + } + // This attempt owns a unique item id. The row recheck keeps the + // existing compensation contract, while deleting/restoring this id + // cannot touch a successor's credential even if one commits later. + const restoredCredential = yield* restoreCredentialSnapshotsWithRecheck( + [secretWrite], + deps.fuma.transaction( + Effect.gen(function* () { + const current = yield* deps.fuma.use("oauth_client.compensate.recheck", (db) => + looseDb(db).findFirst("oauth_client", { + where: (b: any) => + b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), + }), + ); + const currentRowId = (current as Record | null)?.["row_id"]; + const expectedRowId = (committed.existing as Record | null)?.[ + "row_id" + ]; + return committed.existing === null + ? current === null + : currentRowId === expectedRowId; + }), + ), + ); + if (Predicate.isTagged(restoredCredential, "Superseded")) { + return yield* new StorageError({ + message: `Failed to store the OAuth client secret for ${input.owner}/${String(input.slug)}, and credential cleanup was skipped because the compensated row was superseded.`, + cause: writeExit.cause, + }); + } + if (Predicate.isTagged(restoredCredential, "Failed")) { + return yield* new StorageError({ + message: `Failed to store the OAuth client secret for ${input.owner}/${String(input.slug)}, and credential compensation also failed.`, + cause: restoredCredential.cause, + }); + } + return yield* Effect.failCause(writeExit.cause); + } + const previousItemId = (committed.existing as Record | null)?.[ + "client_secret_item_id" + ]; + const provider = deps.defaultWritableProvider(); + if ( + typeof previousItemId === "string" && + previousItemId !== clientSecretItemIdValue && + provider?.delete + ) { + yield* provider.delete(ProviderItemId.make(previousItemId)).pipe(Effect.ignore); + } + }), + ); + } return input.slug; }); @@ -952,7 +1126,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // the next token refresh, prompting a reconnect (graceful degradation; this // op never cascades into connections). // ----------------------------------------------------------------------- - const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect => + const removeClient = ( + owner: Owner, + slug: OAuthClientSlug, + ): Effect.Effect => Effect.gen(function* () { // Config-declared apps have no row to remove; removing one is an env // change on the host, not a storage operation. Fail loudly rather than @@ -963,6 +1140,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + yield* deps.guardOrgWrite(owner); // "Is there an app at (owner, slug) right now?" — asked twice, for two // different reasons. Before the delete it says whether this call removes // anything at all; after the commit it says whether the secret key still @@ -973,19 +1151,23 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }), ); - const removedRow = yield* findClientRow; - yield* deps.fuma - .use("oauth_client.delete", (db) => - looseDb(db).deleteMany("oauth_client", { - where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), - }), - ) - .pipe(Effect.asVoid); + const removedRow = yield* deps.fuma.transaction( + Effect.gen(function* () { + const existing = yield* findClientRow; + yield* deps.fuma + .use("oauth_client.delete", (db) => + looseDb(db).deleteMany("oauth_client", { + where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), + }), + ) + .pipe(Effect.asVoid); + return existing; + }), + ); // Nothing matched, so this call removed nothing and owns no secret. The // idempotent no-op and the cross-subject miss both land here, and both // used to queue a delete of a key they never had a claim on. if (!removedRow) return; - // Best-effort: drop the secret from the provider so it isn't orphaned. // // Deferred to the outermost commit. This function opens no transaction of @@ -1013,7 +1195,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // an orphaned secret is recoverable, a destroyed live one is not. const recreated = yield* findClientRow; if (recreated) return; - yield* dropSecret.call(provider, ProviderItemId.make(clientSecretItemId(owner, slug))); + const removedItemId = removedRow["client_secret_item_id"]; + if (typeof removedItemId === "string") { + yield* dropSecret.call(provider, ProviderItemId.make(removedItemId)); + } }).pipe(Effect.catch(() => Effect.void)), ); } @@ -1203,8 +1388,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const registerDynamicClient = ( input: RegisterDynamicClientInput, - ): Effect.Effect => + ): Effect.Effect< + OAuthClientSlug, + OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure + > => Effect.gen(function* () { + yield* deps.guardOrgWrite(input.owner); const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); // Resolved before the reuse decision: a persisted client registered with // a DIFFERENT callback must not be reused (strict servers 400 the @@ -1416,9 +1605,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { if (row.client_secret_item_id != null) { const provider = deps.defaultWritableProvider(); if (provider) { - clientSecret = - (yield* provider.get(ProviderItemId.make(String(row.client_secret_item_id)))) ?? - ""; + const itemId = String(row.client_secret_item_id); + const resolved = yield* provider.get(ProviderItemId.make(itemId)); + if ( + resolved === null && + parseCredentialWriteAttempt(row.credential_write) !== null + ) { + return yield* new CredentialWriteIncompleteError({ + message: `OAuth client credential write for ${owner}/${String(slug)} is incomplete; retry the operation.`, + cause: undefined, + }); + } + clientSecret = resolved ?? ""; } } return { @@ -1441,8 +1639,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const start = ( input: OAuthStartInput, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { + // Gate before any session row or upstream exchange: minting a Workspace + // connection (including a reconnect that would replace its credential) + // is a workspace-level change. Personal connections remain member-owned. + yield* deps.guardOrgWrite(input.owner); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), catch: (cause) => @@ -1591,12 +1793,13 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // client_credentials has no callback, so no regional rebind applies. null, ).pipe( - Effect.mapError( - (cause) => - new OAuthStartError({ - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: StorageFailure carries a typed `message` field - message: `Failed to mint OAuth connection: ${cause.message}`, - }), + Effect.mapError((cause) => + Predicate.isTagged(cause, "OrgWriteDeniedError") + ? cause + : new OAuthStartError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: StorageFailure carries a typed `message` field + message: `Failed to mint OAuth connection: ${cause.message}`, + }), ), ); return { status: "connected", connection } as const; @@ -1721,12 +1924,13 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { resolvedEnterprise, metadata.issuer, ).pipe( - Effect.mapError( - (cause) => - new OAuthStartError({ - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: StorageFailure carries a typed `message` field - message: `Failed to mint OAuth connection: ${cause.message}`, - }), + Effect.mapError((cause) => + Predicate.isTagged(cause, "OrgWriteDeniedError") + ? cause + : new OAuthStartError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: StorageFailure carries a typed `message` field + message: `Failed to mint OAuth connection: ${cause.message}`, + }), ), ); yield* recordEnterpriseManagedRollout({ @@ -1868,7 +2072,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const complete = ( input: OAuthCompleteInput, - ): Effect.Effect => + ): Effect.Effect< + Connection, + OAuthCompleteError | OAuthSessionNotFoundError | OrgWriteDeniedError | StorageFailure + > => Effect.gen(function* () { const sessionRow = yield* deps.fuma.use("oauth_session.findFirst", (db) => looseDb(db).findFirst("oauth_session", { @@ -2003,13 +2210,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // client's configured one, so refresh redeems against the same region. tokenUrl === client.tokenUrl ? null : tokenUrl, ).pipe( - Effect.mapError( - (cause) => - new OAuthCompleteError({ - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: StorageFailure carries a typed `message` field - message: `Failed to mint OAuth connection: ${cause.message}`, - restartRequired: false, - }), + Effect.mapError((cause) => + Predicate.isTagged(cause, "OrgWriteDeniedError") + ? cause + : new OAuthCompleteError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: StorageFailure carries a typed `message` field + message: `Failed to mint OAuth connection: ${cause.message}`, + restartRequired: false, + }), ), ); @@ -2053,9 +2261,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ); // ----------------------------------------------------------------------- - // Mint the connection from a freshly exchanged token: store the access - // value (+ refresh) in the default writable provider, then write the - // connection row with OAuth lifecycle fields + produce its tools. + // Mint the connection from a freshly exchanged token: hand the access value + // (+ refresh) to the executor, which commits the row first, persists the + // credentials with compensation, then produces the connection's tools. // ----------------------------------------------------------------------- const mintFromToken = ( target: { @@ -2076,8 +2284,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { /** Regional token endpoint override to persist when the code was redeemed * off the client's configured host; null to use the client's token URL. */ oauthTokenUrl: string | null, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { + // The token exchange may outlive the role that admitted `start`. Re-read + // the live binding at the first persistence sink so a demotion takes + // effect before either access or refresh credentials are stored. + yield* deps.guardOrgWrite(target.owner); const provider = deps.defaultWritableProvider(); if (!provider || !provider.set) { return yield* new StorageError({ @@ -2087,12 +2299,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } const itemId = accessItemId(target.owner, target.integration, target.name); - yield* provider.set(ProviderItemId.make(itemId), token.access_token); - let refreshItemId: string | null = null; if (token.refresh_token) { refreshItemId = refreshItemIdFor(itemId); - yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token); } const oauthScope = recordedOAuthScope(token, requestedScopes); @@ -2125,6 +2334,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { derivedIdentityLabel: token.idTokenIdentityLabel ?? null, provider: String(provider.key), itemId, + credentialValues: [ + { itemId, value: token.access_token }, + ...(refreshItemId === null || token.refresh_token === undefined + ? [] + : [{ itemId: refreshItemId, value: token.refresh_token }]), + ], oauthClient: OAuthClientSlug.make(client.slug), oauthClientOwner: clientOwner, refreshItemId, @@ -2161,8 +2376,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { enterprise: EnterpriseManagedStartInput & { readonly subjectTokenType: SubjectTokenType }, /** The Resource Authorization Server's issuer identifier, as discovered. */ audience: string, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { + yield* deps.guardOrgWrite(target.owner); const provider = deps.defaultWritableProvider(); if (!provider || !provider.set) { return yield* new StorageError({ @@ -2172,9 +2388,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } const itemId = accessItemId(target.owner, target.integration, target.name); - yield* provider.set(ProviderItemId.make(itemId), grant.token.access_token); const subjectTokenItemId = refreshItemIdFor(itemId); - yield* provider.set(ProviderItemId.make(subjectTokenItemId), enterprise.subjectToken); yield* Effect.annotateCurrentSpan({ "executor.oauth.has_advertised_expiry": typeof grant.token.expires_in === "number", @@ -2189,6 +2403,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { derivedIdentityLabel: grant.token.idTokenIdentityLabel ?? null, provider: String(provider.key), itemId, + credentialValues: [ + { itemId, value: grant.token.access_token }, + { itemId: subjectTokenItemId, value: enterprise.subjectToken }, + ], oauthClient: OAuthClientSlug.make(client.slug), oauthClientOwner: clientOwner, refreshItemId: subjectTokenItemId, diff --git a/packages/core/sdk/src/org-write-access.ts b/packages/core/sdk/src/org-write-access.ts new file mode 100644 index 0000000000..f3597f9d63 --- /dev/null +++ b/packages/core/sdk/src/org-write-access.ts @@ -0,0 +1,33 @@ +import { Context, Effect, Ref } from "effect"; + +/** Workspace-settings authorization bound to the currently executing request. */ +export type OrgWriteAccess = "allowed" | "denied"; + +/** + * Fiber-local workspace-settings authorization for request-bound executors. + * + * The denied default makes a missing request binding fail closed. Non-session + * executors continue to use their explicit {@link ExecutorConfig.orgWrites} + * value (or the allowed default) and never consult this reference. + */ +export interface OrgWriteAccessState { + /** Mutable value inherited by a detached execution and refreshed on resume. */ + readonly current: Ref.Ref; +} + +/** Create an isolated request/execution authorization state. */ +export const makeOrgWriteAccessState = (access: OrgWriteAccess): OrgWriteAccessState => ({ + current: Ref.makeUnsafe(access), +}); + +/** Request-local workspace-write authorization inherited by child fibers. */ +export const CurrentOrgWriteAccess = Context.Reference( + "@executor-js/sdk/CurrentOrgWriteAccess", + { defaultValue: () => makeOrgWriteAccessState("denied") }, +); + +/** Read the effective authorization at a workspace-write sink. */ +export const currentOrgWriteAccess: Effect.Effect = Effect.gen(function* () { + const state = yield* CurrentOrgWriteAccess; + return yield* Ref.get(state.current); +}); diff --git a/packages/core/sdk/src/org-writes.test.ts b/packages/core/sdk/src/org-writes.test.ts new file mode 100644 index 0000000000..f30c5d14e7 --- /dev/null +++ b/packages/core/sdk/src/org-writes.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ProviderItemId, + ProviderKey, + ToolAddress, + ToolName, +} from "./ids"; +import { createExecutor } from "./executor"; +import { CurrentOrgWriteAccess, makeOrgWriteAccessState } from "./org-write-access"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestConfig } from "./testing"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; + +// --------------------------------------------------------------------------- +// `ExecutorConfig.orgWrites` — the workspace-settings gate. +// +// A `"denied"` binding (a plain member) may USE workspace resources — read +// them, execute tools over org connections — but every user-intent +// workspace-level mutation refuses with `OrgWriteDeniedError`: Workspace +// connections, org-owned policies / OAuth clients, and the tenant-shared +// integration catalog. Personal connections and OAuth apps remain member-owned. +// `"allowed"` (admins, and hosts with no role model) behaves exactly as before. +// +// The fixtures build TWO executors over ONE test database: an admin +// (default `orgWrites`) that seeds the workspace, and a member +// (`orgWrites: "denied"`) that the assertions run against. +// --------------------------------------------------------------------------- + +const memoryProvider = (): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + delete: (id) => Effect.sync(() => void store.delete(String(id))), + has: (id) => Effect.sync(() => store.has(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((key) => ({ + id: ProviderItemId.make(key), + name: key, + })), + ), + }; +}; + +const INTEG = IntegrationSlug.make("vercel"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const demoPlugin = definePlugin(() => ({ + id: "demo" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("deploy"), description: "deploy" }], + }), + invokeTool: ({ toolRow, credential }) => + Effect.succeed({ ran: toolRow.name, value: credential.value }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Vercel", + config: {}, + }), + seedFresh: () => + ctx.core.integrations.register({ + slug: IntegrationSlug.make("fresh"), + description: "Fresh", + config: {}, + }), + }), +}))(); + +const setup = () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const admin = yield* createExecutor(config); + const member = yield* createExecutor({ ...config, orgWrites: "denied" }); + yield* Effect.addFinalizer(() => + admin.close().pipe(Effect.andThen(member.close()), Effect.ignore), + ); + yield* admin.demo.seed(); + return { admin, member }; + }); + +const expectOrgWriteDenied = (effect: Effect.Effect) => + effect.pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toMatchObject({ _tag: "OrgWriteDeniedError" }); + }), + ); + +describe("orgWrites: denied", () => { + it.effect("reads a live session binding at every workspace-write sink", () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const executor = yield* createExecutor({ + ...config, + orgWrites: "request", + }); + yield* Effect.addFinalizer(() => executor.close().pipe(Effect.ignore)); + const policy = yield* executor.demo.seed().pipe( + Effect.andThen( + executor.policies.create({ + owner: "org", + pattern: "*", + action: "block", + }), + ), + Effect.provideService(CurrentOrgWriteAccess, makeOrgWriteAccessState("allowed")), + ); + + yield* expectOrgWriteDenied( + executor.policies + .update({ + id: policy.id, + owner: "org", + action: "approve", + }) + .pipe(Effect.provideService(CurrentOrgWriteAccess, makeOrgWriteAccessState("denied"))), + ); + }).pipe(Effect.scoped), + ); + + it.effect("refuses org tool policies but accepts user ones", () => + Effect.gen(function* () { + const { member } = yield* setup(); + yield* expectOrgWriteDenied( + member.policies.create({ owner: "org", pattern: "*", action: "block" }), + ); + const mine = yield* member.policies.create({ + owner: "user", + pattern: "*", + action: "require_approval", + }); + yield* expectOrgWriteDenied( + member.policies.update({ id: mine.id, owner: "org", action: "block" }), + ); + yield* expectOrgWriteDenied(member.policies.remove({ id: mine.id, owner: "org" })); + yield* member.policies.update({ + id: mine.id, + owner: "user", + action: "approve", + }); + yield* member.policies.remove({ id: mine.id, owner: "user" }); + }).pipe(Effect.scoped), + ); + + it.effect("refuses Workspace connections but accepts Personal connections", () => + Effect.gen(function* () { + const { admin, member } = yield* setup(); + yield* expectOrgWriteDenied( + member.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }), + ); + const mine = yield* member.connections.create({ + owner: "user", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + value: "user-token", + }); + const mineRef = { + owner: mine.owner, + integration: mine.integration, + name: mine.name, + }; + yield* member.connections.update(mineRef, { + description: "my credential", + }); + expect(yield* member.connections.refresh(mineRef)).toHaveLength(1); + + const shared = yield* admin.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }); + const ref = { + owner: shared.owner, + integration: shared.integration, + name: shared.name, + }; + yield* expectOrgWriteDenied(member.connections.update(ref, { description: "renamed" })); + yield* expectOrgWriteDenied(member.connections.refresh(ref)); + yield* expectOrgWriteDenied(member.connections.remove(ref)); + expect(yield* admin.connections.refresh(ref)).toHaveLength(1); + yield* member.connections.remove(mineRef); + }).pipe(Effect.scoped), + ); + + it.effect("still USES the workspace: reads org rows and executes org-connection tools", () => + Effect.gen(function* () { + const { admin, member } = yield* setup(); + yield* admin.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }); + const visible = yield* member.connections.list({ owner: "org" }); + expect(visible.map((c) => String(c.name))).toContain("shared"); + const out = yield* member.execute(ToolAddress.make("tools.vercel.org.shared.deploy"), {}); + expect(out).toMatchObject({ ran: "deploy", value: "org-token" }); + }).pipe(Effect.scoped), + ); + + it.effect("refuses catalog mutations: new registration, update, health check, removal", () => + Effect.gen(function* () { + const { member } = yield* setup(); + // A NEW slug is refused through the plugin ctx register path (the seam + // every add-integration flow funnels through)… + yield* expectOrgWriteDenied(member.demo.seedFresh()); + const fresh = yield* member.integrations.get(IntegrationSlug.make("fresh")); + expect(fresh).toBeNull(); + // …and so are the public catalog mutations. + yield* expectOrgWriteDenied(member.integrations.update(INTEG, { name: "Renamed" })); + yield* expectOrgWriteDenied(member.integrations.healthCheck.set(INTEG, null)); + yield* expectOrgWriteDenied(member.integrations.remove(INTEG)); + }).pipe(Effect.scoped), + ); + + it.effect("refuses integration replacement by a member", () => + Effect.gen(function* () { + const { member } = yield* setup(); + yield* expectOrgWriteDenied(member.demo.seed()); + const row = yield* member.integrations.get(INTEG); + expect(row?.slug).toBe(INTEG); + }).pipe(Effect.scoped), + ); + + it.effect("allows subjectless system re-registration during boot convergence", () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const admin = yield* createExecutor(config); + const { subject: _subject, ...systemConfig } = config; + const system = yield* createExecutor({ + ...systemConfig, + orgWrites: "denied", + }); + yield* Effect.addFinalizer(() => + admin.close().pipe(Effect.andThen(system.close()), Effect.ignore), + ); + yield* admin.demo.seed(); + + yield* system.demo.seed(); + const row = yield* system.integrations.get(INTEG); + expect(row?.slug).toBe(INTEG); + }).pipe(Effect.scoped), + ); + + it.effect("refuses org OAuth clients/connect flows but accepts Personal ones", () => + Effect.gen(function* () { + const { member } = yield* setup(); + yield* expectOrgWriteDenied( + member.oauth.createClient({ + owner: "org", + slug: OAuthClientSlug.make("shared-app"), + authorizationUrl: "https://example.com/authorize", + tokenUrl: "https://example.com/token", + grant: "authorization_code", + clientId: "client-id", + clientSecret: "", + }), + ); + yield* expectOrgWriteDenied( + member.oauth.removeClient("org", OAuthClientSlug.make("shared-app")), + ); + yield* expectOrgWriteDenied( + member.oauth.start({ + owner: "org", + clientOwner: "org", + client: OAuthClientSlug.make("shared-app"), + integration: INTEG, + template: TEMPLATE, + name: ConnectionName.make("shared"), + }), + ); + // Personal clients stay open. + const slug = yield* member.oauth.createClient({ + owner: "user", + slug: OAuthClientSlug.make("my-app"), + authorizationUrl: "https://example.com/authorize", + tokenUrl: "https://example.com/token", + grant: "authorization_code", + clientId: "client-id", + clientSecret: "", + }); + expect(String(slug)).toBe("my-app"); + const started = yield* member.oauth.start({ + owner: "user", + clientOwner: "user", + client: slug, + integration: INTEG, + template: TEMPLATE, + name: ConnectionName.make("mine"), + newConnection: true, + }); + expect(started.status).toBe("redirect"); + yield* member.oauth.removeClient("user", slug); + }).pipe(Effect.scoped), + ); + + it.effect("rechecks workspace authorization before persisting OAuth callback tokens", () => + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: [] }); + const { admin, member } = yield* setup(); + const client = OAuthClientSlug.make("demotion-app"); + yield* admin.oauth.createClient({ + owner: "org", + slug: client, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + const beforeItems = yield* member.providers.items(ProviderKey.make("memory")); + const started = yield* admin.oauth.start({ + owner: "org", + clientOwner: "org", + client, + integration: INTEG, + template: TEMPLATE, + name: ConnectionName.make("demoted"), + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + + yield* expectOrgWriteDenied( + member.oauth.complete({ state: started.state, code: callback.code }), + ); + expect(yield* member.connections.list({ owner: "org" })).toEqual([]); + expect(yield* member.providers.items(ProviderKey.make("memory"))).toEqual(beforeItems); + }).pipe(Effect.scoped), + ); +}); + +describe("orgWrites: default (allowed)", () => { + it.effect("admin bindings mutate workspace-level state as before", () => + Effect.gen(function* () { + const { admin } = yield* setup(); + const policy = yield* admin.policies.create({ + owner: "org", + pattern: "*", + action: "require_approval", + }); + expect(policy.owner).toBe("org"); + yield* admin.policies.remove({ id: policy.id, owner: "org" }); + yield* admin.integrations.update(INTEG, { name: "Vercel (renamed)" }); + const row = yield* admin.integrations.get(INTEG); + expect(row?.name).toBe("Vercel (renamed)"); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/core/sdk/src/plugin-after-commit.test.ts b/packages/core/sdk/src/plugin-after-commit.test.ts index 9449ae605c..2cb466a67f 100644 --- a/packages/core/sdk/src/plugin-after-commit.test.ts +++ b/packages/core/sdk/src/plugin-after-commit.test.ts @@ -5,12 +5,14 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug, + OAuthClientSlug, ProviderItemId, ProviderKey, ToolName, } from "./ids"; import { definePlugin } from "./plugin"; import { makeTestExecutor } from "./test-config"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; // A plugin's `removeConnection` runs INSIDE core's removal transaction, which is // what makes its database work atomic with the row deletions. The same property @@ -48,6 +50,15 @@ const revokingPlugin = (revoked: string[]) => storage: () => ({}), resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), + describeAuthMethods: () => [ + { + id: "oauth", + label: "OAuth", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: [] }, + }, + ], invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), /** Stands in for "revoke the token at the provider's API" — the archetypal * irreversible, outside-the-database cleanup. */ @@ -61,6 +72,27 @@ const revokingPlugin = (revoked: string[]) => seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), inTransaction: (effect: Effect.Effect) => ctx.transaction(effect), + createThenRollback: () => + ctx.transaction( + Effect.gen(function* () { + yield* ctx.connections.create({ + ...REF, + template: TEMPLATE, + value: "rolled-back-secret", + }); + return yield* Effect.fail("rollback" as const); + }), + ), + createInTransaction: () => + ctx.transaction( + ctx.connections.create({ + ...REF, + template: TEMPLATE, + value: "committed-secret", + }), + ), + credentialValues: () => Effect.sync(() => [...store.values()]), + resolveValue: () => ctx.connections.resolveValue(REF), }), }; })(); @@ -113,4 +145,116 @@ describe("ctx.afterCommit inside a lifecycle hook", () => { expect(revoked).toEqual([]); }), ); + + it.effect("discards required credential writes when an outer transaction rolls back", () => + Effect.gen(function* () { + const executor = yield* setup([]); + + const outcome = yield* Effect.exit(executor.demo.createThenRollback()); + + expect(Exit.isFailure(outcome)).toBe(true); + expect(yield* executor.connections.get(REF)).toBeNull(); + expect(yield* executor.demo.credentialValues()).toEqual([]); + }), + ); + + it.effect("finishes required credential writes before a committed outer call returns", () => + Effect.gen(function* () { + const executor = yield* setup([]); + + yield* executor.demo.createInTransaction(); + + expect(yield* executor.connections.get(REF)).not.toBeNull(); + expect(yield* executor.demo.credentialValues()).toEqual(["committed-secret"]); + }), + ); + + it.effect( + "discards new and replacement OAuth-client secrets when an outer transaction rolls back", + () => + Effect.gen(function* () { + const executor = yield* setup([]); + const slug = OAuthClientSlug.make("transactional-client"); + const client = (clientId: string, clientSecret: string) => ({ + owner: "org" as const, + slug, + authorizationUrl: "https://example.test/authorize", + tokenUrl: "https://example.test/token", + grant: "client_credentials" as const, + clientId, + clientSecret, + }); + + const newResult = yield* Effect.exit( + executor.demo.inTransaction( + executor.oauth + .createClient(client("new-id", "new-secret")) + .pipe(Effect.andThen(Effect.fail("rollback" as const))), + ), + ); + expect(Exit.isFailure(newResult)).toBe(true); + expect(yield* executor.oauth.listClients()).toEqual([]); + expect(yield* executor.demo.credentialValues()).toEqual([]); + + yield* executor.oauth.createClient(client("original-id", "original-secret")); + const before = yield* executor.demo.credentialValues(); + const replacementResult = yield* Effect.exit( + executor.demo.inTransaction( + executor.oauth + .createClient(client("replacement-id", "replacement-secret")) + .pipe(Effect.andThen(Effect.fail("rollback" as const))), + ), + ); + expect(Exit.isFailure(replacementResult)).toBe(true); + expect(yield* executor.oauth.listClients()).toEqual([ + expect.objectContaining({ clientId: "original-id" }), + ]); + expect(yield* executor.demo.credentialValues()).toEqual(before); + }), + ); + + it.effect( + "discards new and replacement OAuth-connection tokens when an outer transaction rolls back", + () => + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ + clients: { "transaction-client": "transaction-secret" }, + }); + const executor = yield* setup([]); + const slug = OAuthClientSlug.make("transaction-connection-client"); + yield* executor.oauth.createClient({ + owner: "org", + slug, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "client_credentials", + clientId: "transaction-client", + clientSecret: "transaction-secret", + }); + const start = executor.oauth.start({ + ...REF, + client: slug, + clientOwner: "org" as const, + template: TEMPLATE, + }); + const beforeNew = yield* executor.demo.credentialValues(); + + const newResult = yield* Effect.exit( + executor.demo.inTransaction(start.pipe(Effect.andThen(Effect.fail("rollback" as const)))), + ); + expect(Exit.isFailure(newResult)).toBe(true); + expect(yield* executor.connections.get(REF)).toBeNull(); + expect(yield* executor.demo.credentialValues()).toEqual(beforeNew); + + yield* start; + const originalValue = yield* executor.demo.resolveValue(); + const beforeReplacement = yield* executor.demo.credentialValues(); + const replacementResult = yield* Effect.exit( + executor.demo.inTransaction(start.pipe(Effect.andThen(Effect.fail("rollback" as const)))), + ); + expect(Exit.isFailure(replacementResult)).toBe(true); + expect(yield* executor.demo.resolveValue()).toBe(originalValue); + expect(yield* executor.demo.credentialValues()).toEqual(beforeReplacement); + }).pipe(Effect.scoped), + ); }); diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 5751a28577..2ace32f891 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -48,6 +48,7 @@ import type { IntegrationNotFoundError, IntegrationRemovalNotAllowedError, InvalidConnectionInputError, + OrgWriteDeniedError, } from "./errors"; import type { OAuthService } from "./oauth-client"; import type { CredentialProvider, ProviderEntry } from "./provider"; @@ -163,8 +164,16 @@ export interface PluginCtx { readonly core: { readonly integrations: { - /** Register / replace this plugin's integration in the catalog. */ - readonly register: (input: RegisterIntegrationInput) => Effect.Effect; + /** Authorize a user-intent workspace catalog write before a plugin starts + * external work or writes to storage outside the catalog transaction. */ + readonly authorizeWrite: () => Effect.Effect; + /** Register / replace this plugin's integration in the catalog. Both + * operations are workspace-level changes gated by the executor's + * `orgWrites` binding for end-user principals. Subjectless system + * executors may re-register an existing row during boot convergence. */ + readonly register: ( + input: RegisterIntegrationInput, + ) => Effect.Effect; readonly update: ( slug: IntegrationSlug, patch: { @@ -172,21 +181,24 @@ export interface PluginCtx { readonly description?: string; readonly config?: IntegrationConfig; }, - ) => Effect.Effect; + ) => Effect.Effect; readonly list: () => Effect.Effect; readonly get: ( slug: IntegrationSlug, ) => Effect.Effect; readonly remove: ( slug: IntegrationSlug, - ) => Effect.Effect; + ) => Effect.Effect< + void, + IntegrationRemovalNotAllowedError | OrgWriteDeniedError | StorageFailure + >; /** Declare (or clear, with null) the integration's health check. Core * owns this storage; plugins call it e.g. to install a zero-config * default probe at registration time. */ readonly setHealthCheck: ( slug: IntegrationSlug, spec: HealthCheckSpec | null, - ) => Effect.Effect; + ) => Effect.Effect; readonly detect: ( url: string, ) => Effect.Effect; @@ -195,9 +207,15 @@ export interface PluginCtx { }; readonly policies: { readonly list: () => Effect.Effect; - readonly create: (input: CreateToolPolicyInput) => Effect.Effect; - readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; - readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; + readonly create: ( + input: CreateToolPolicyInput, + ) => Effect.Effect; + readonly update: ( + input: UpdateToolPolicyInput, + ) => Effect.Effect; + readonly remove: ( + input: RemoveToolPolicyInput, + ) => Effect.Effect; }; }; @@ -212,6 +230,7 @@ export interface PluginCtx { | ConnectionAlreadyExistsError | CredentialProviderNotRegisteredError | InvalidConnectionInputError + | OrgWriteDeniedError | StorageFailure >; readonly list: (filter?: { @@ -223,15 +242,15 @@ export interface PluginCtx { readonly update: ( ref: ConnectionRef, input: UpdateConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( ref: ConnectionRef, - ) => Effect.Effect; + ) => Effect.Effect; readonly refresh: ( ref: ConnectionRef, ) => Effect.Effect< readonly Tool[], - ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure + ConnectionNotFoundError | IntegrationNotFoundError | OrgWriteDeniedError | StorageFailure >; /** Run the integration's declared health check against a saved connection * and persist the verdict. `ifStaleMs` serves the persisted verdict when diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index beb9703c49..1b5683aeba 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -13,6 +13,8 @@ import { ToolName, } from "./ids"; import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; +import { createExecutor } from "./executor"; +import type { FumaDb } from "./fuma-runtime"; import { effectivePolicyFromSorted, isValidPattern, @@ -21,7 +23,7 @@ import { } from "./policies"; import { definePlugin, tool } from "./plugin"; import type { CredentialProvider } from "./provider"; -import { makeTestExecutor } from "./testing"; +import { makeTestConfig, makeTestExecutor } from "./testing"; // --------------------------------------------------------------------------- // Pure unit tests — pattern matcher + resolution. No executor required. @@ -358,6 +360,35 @@ const setupExecutor = () => ), ); +/** Model a concurrent remover that wins immediately after policy update. */ +const removePolicyAfterUpdate = (db: FumaDb, armed: () => boolean): FumaDb => { + const wrap = (inner: FumaDb): FumaDb => + new Proxy(inner, { + get(target, property) { + if (property === "withContext") { + return (context: unknown) => + wrap((target.withContext as (value: unknown) => FumaDb)(context)); + } + if (property === "transaction") { + return (run: (transactionDb: FumaDb) => Promise) => + target.transaction((transactionDb) => run(wrap(transactionDb as FumaDb))); + } + if (property === "updateMany") { + return async (...args: Parameters) => { + const [table, input] = args; + const result = await target.updateMany(...args); + if (armed() && table === "tool_policy") { + await target.deleteMany(table, { where: input.where }); + } + return result; + }; + } + return Reflect.get(target, property); + }, + }); + return wrap(db); +}; + describe("executor.policies", () => { it.effect("list is empty when no rules exist", () => Effect.gen(function* () { @@ -482,6 +513,40 @@ describe("executor.policies", () => { }), ); + it.effect("fails when the policy vanishes during update", () => + Effect.gen(function* () { + let armed = false; + const config = makeTestConfig({ plugins: [policyTestPlugin()] as const }); + const executor = yield* createExecutor({ + ...config, + db: removePolicyAfterUpdate(config.db, () => armed), + }); + yield* Effect.addFinalizer(() => + executor + .close() + .pipe(Effect.andThen(Effect.promise(() => config.testDb.close())), Effect.ignore), + ); + const created = yield* executor.policies.create({ + owner: "org", + pattern: "vercel.*", + action: "require_approval", + }); + armed = true; + + const result = yield* executor.policies + .update({ id: String(created.id), owner: "org", action: "block" }) + .pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + expect( + Result.match(result, { + onFailure: (failure) => String(failure), + onSuccess: () => "", + }), + ).toContain(`Tool policy disappeared while it was being updated: ${created.id}`); + expect(yield* executor.policies.list()).toEqual([created]); + }).pipe(Effect.scoped), + ); + it.effect("remove deletes the rule", () => Effect.gen(function* () { const executor = yield* setupExecutor(); diff --git a/packages/core/sdk/src/provider.ts b/packages/core/sdk/src/provider.ts index d2a895a771..631d705e7f 100644 --- a/packages/core/sdk/src/provider.ts +++ b/packages/core/sdk/src/provider.ts @@ -30,6 +30,10 @@ export interface CredentialProvider { * before its template is applied. The provider interprets the id. */ readonly get: (id: ProviderItemId) => Effect.Effect; readonly has?: (id: ProviderItemId) => Effect.Effect; + /** Unconditional replacement. This public seam intentionally promises no + * compare-and-set/version token: file, OS-keychain, remote-vault, and + * external implementations do not share an atomic conditional-write + * primitive. Callers must not infer successor safety from a preceding get. */ readonly set?: (id: ProviderItemId, value: string) => Effect.Effect; readonly delete?: (id: ProviderItemId) => Effect.Effect; /** Browse entries for discovery (pick a 1Password item). Optional — some diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 86b6cd7d25..391c12e3fa 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -59,6 +59,7 @@ export { IntegrationNotFoundError, IntegrationAlreadyExistsError, IntegrationRemovalNotAllowedError, + OrgWriteDeniedError, ConnectionAlreadyExistsError, ConnectionNotFoundError, InvalidConnectionInputError, diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index cbb64e1dad..17b8fbc321 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -134,6 +134,10 @@ export type TestConfigOptions["onIntegrationChange"]; readonly firstPartyOAuthClients?: ExecutorConfig["firstPartyOAuthClients"]; readonly enterpriseManagedRollout?: ExecutorConfig["enterpriseManagedRollout"]; + /** Workspace-settings permission for the test binding (see + * `ExecutorConfig.orgWrites`). Defaults to allowed, like production hosts + * with no role model. */ + readonly orgWrites?: ExecutorConfig["orgWrites"]; }; export const makeTestConfig = ( @@ -173,6 +177,7 @@ export const makeTestConfig = ; + approvalWaiters: Map< + string, + Deferred.Deferred<{ + readonly response: ResumeResponse; + readonly orgWriteAccess: "allowed" | "denied"; + }> + >; alarm: () => Promise; ctx: MemoryStorage; dbHandle: { readonly end: () => void } | null; @@ -158,6 +172,15 @@ type HarnessSession = { identity: McpApprovalOwner, response: ResumeResponse, ) => Promise; + resumeExecutionForApproval: ( + executionId: string, + identity: McpApprovalPrincipal, + response: ResumeResponse, + ) => Promise; + waitForApprovalResponse: (executionId: string) => Effect.Effect<{ + readonly response: ResumeResponse; + readonly orgWriteAccess: "allowed" | "denied"; + } | null>; validateMcpSessionOwner: (identity: { readonly accountId: string; readonly organizationId: string; @@ -248,6 +271,7 @@ const makeHarnessSession = async (): Promise => { const sessionMeta: SessionMeta = { organizationId: "org-1", organizationName: "Org 1", + orgRoleModel: "organization", userId: "user-1", resource: defaultMcpResource, }; @@ -256,6 +280,8 @@ const makeHarnessSession = async (): Promise => { await server.connect(new StaleCloseTransport()); const session = Object.create(McpAgentSessionDOBase.prototype) as HarnessSession; + session.approvalResponses = new Map(); + session.approvalWaiters = new Map(); session.ctx = storage; session.dbHandle = { end: () => undefined }; session.engine = makeEngine().engine; @@ -279,6 +305,38 @@ const makeHarnessSession = async (): Promise => { return session; }; +it("records a demoted browser approver's current role in a waiting decision", async () => { + const session = await makeHarnessSession(); + const executionId = "exec-browser-demotion"; + session.engine = { + ...makeEngine().engine, + getPausedExecution: (id) => + Effect.succeed( + id === executionId + ? { + id, + elicitationContext: { + address: ToolAddress.make("executor.coreTools.policies.create"), + args: {}, + request: FormElicitation.make({ message: "Approve?", requestedSchema: {} }), + }, + } + : null, + ), + }; + + const waiting = Effect.runPromise(session.waitForApprovalResponse(executionId)); + await Promise.resolve(); + const result = await session.resumeExecutionForApproval( + executionId, + { accountId: "user-1", organizationId: "org-1", orgRole: "member" }, + approval, + ); + + expect(result.status).toBe("ok"); + await expect(waiting).resolves.toEqual({ response: approval, orgWriteAccess: "denied" }); +}); + // The negotiated MCP-Apps capability arrives once, at `initialize`, and lives // in the rebuilt server's memory. These pin the storage round-trip that lets a // cold-restored session rebuild with it instead of silently downgrading every @@ -294,6 +352,7 @@ describe("McpAgentSessionDOBase apps capability persistence", () => { const baseMeta: SessionMeta = { organizationId: "org-1", organizationName: "Org 1", + orgRoleModel: "organization", userId: "user-1", resource: defaultMcpResource, }; @@ -328,6 +387,26 @@ describe("McpAgentSessionDOBase apps capability persistence", () => { expect(await storage.get("session-meta")).toMatchObject({ appsEnabled: false }); }); + it("loads persisted pre-role-model metadata through the fail-closed arm", async () => { + const legacyStored = { + organizationId: "org-1", + organizationName: "Org 1", + userId: "user-1", + orgRole: "admin", + resource: defaultMcpResource, + } as const; + const { session } = await makeCapabilitySession(legacyStored); + + const loaded = await Effect.runPromise(session.loadSessionMeta()); + + expect(loaded).toMatchObject({ + organizationId: "org-1", + orgRoleModel: "organization", + resource: defaultMcpResource, + }); + expect(loaded).not.toHaveProperty("orgRole"); + }); + // `init` runs again on every cold restore and rebuilds meta from the bearer // token, which carries no capabilities. If that overwrite won, restoring the // session would erase the very bit meant to survive it. @@ -393,12 +472,14 @@ describe("McpAgentSessionDOBase cold-restore meta reuse", () => { organizationId: "org-1", organizationName: "Org One", organizationSlug: "org-one", + orgRoleModel: "organization", userId: "user-1", resource: defaultMcpResource, }; const token = { organizationId: "org-1", + orgRoleModel: "organization" as const, userId: "user-1", elicitationMode: "model" as const, resource: defaultMcpResource, @@ -428,6 +509,7 @@ describe("McpAgentSessionDOBase cold-restore meta reuse", () => { organizationId: t.organizationId, organizationName: stored.organizationName, organizationSlug: stored.organizationSlug, + orgRoleModel: stored.orgRoleModel, userId: t.userId, resource: defaultMcpResource, } satisfies SessionMeta); @@ -470,6 +552,7 @@ describe("McpAgentSessionDOBase cold-restore meta reuse", () => { return Effect.succeed({ organizationId: t.organizationId, organizationName: "Freshly Resolved", + orgRoleModel: "organization", userId: t.userId, resource: defaultMcpResource, } satisfies SessionMeta); @@ -716,6 +799,7 @@ describe("McpAgentSessionDOBase init survives a platform reset of its bookkeepin const sessionMeta: SessionMeta = { organizationId: "org-1", organizationName: "Org 1", + orgRoleModel: "organization", userId: "user-1", resource: defaultMcpResource, }; @@ -1019,6 +1103,7 @@ describe("McpAgentSessionDOBase residency cap eviction", () => { const residencySessionMeta = (organizationId: string): SessionMeta => ({ organizationId, organizationName: "Org 1", + orgRoleModel: "organization", userId: "user-1", resource: defaultMcpResource, }); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index dcdfc90519..993fab3783 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -15,10 +15,15 @@ import { import { PAUSED_APPROVAL_TIMEOUT_MS, formatMcpExecutionOutcome, + type BrowserApprovalDecision, type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; +import { + ResumeResponsePayload, + decodeResumeResponse, +} from "@executor-js/host-mcp/browser-approval"; import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors"; @@ -50,10 +55,11 @@ import { RESIDENT_RUNTIME_SOFT_CAP, touchResidentSession, } from "./session-runtime-residency"; +import type { OrgRoleMetadata } from "./role-metadata"; export type IncomingTraceHeaders = IncomingPropagationHeaders; -export interface McpSessionInit { +interface McpSessionInitBase { readonly organizationId: string; /** The organization's display name, as the worker resolved it while * authorizing this very request. Carried so the session DO never has to @@ -77,6 +83,9 @@ export interface McpSessionInit { readonly webOrigin?: string; } +/** Live session initialization metadata from an authenticated principal. */ +export type McpSessionInit = McpSessionInitBase & OrgRoleMetadata; + export interface McpSessionProps extends Record { readonly session: McpSessionInit; readonly propagation?: IncomingTraceHeaders; @@ -87,6 +96,11 @@ export type McpApprovalOwner = { readonly organizationId: string; }; +/** Authenticated browser approver with a freshly resolved organization role. */ +export type McpApprovalPrincipal = McpApprovalOwner & { + readonly orgRole: "admin" | "member"; +}; + type McpSessionApprovalErrorResult = | { readonly status: "not_found" } | { readonly status: "forbidden" }; @@ -121,7 +135,7 @@ export interface SessionDbHandle { readonly end: () => Promise | void; } -export interface SessionMeta { +interface SessionMetaBase { readonly organizationId: string; readonly organizationName: string; /** The org's URL slug, when the host's `resolveSessionMeta` carried one. @@ -154,14 +168,25 @@ export interface SessionMeta { readonly appsEnabled?: boolean; } +/** Durable session metadata, including the pre-role-model persisted shape. */ +export type SessionMeta = SessionMetaBase & + ( + | OrgRoleMetadata + | { + /** Missing only on records written before role models were persisted. */ + readonly orgRoleModel?: undefined; + readonly orgRole?: "admin" | "member"; + } + ); + export interface BuiltMcpServer { readonly mcpServer: McpServer; readonly engine: ExecutionEngine; } export interface BrowserApprovalStore { - readonly takeResponse: (executionId: string) => Effect.Effect; - readonly waitForResponse: (executionId: string) => Effect.Effect; + readonly takeResponse: (executionId: string) => Effect.Effect; + readonly waitForResponse: (executionId: string) => Effect.Effect; } const SESSION_META_KEY = "session-meta"; @@ -180,6 +205,11 @@ const MCP_MESSAGE_HEADER = "cf-mcp-message"; const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000; const MCP_STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`; +const BrowserApprovalDecisionStorage = Schema.Struct({ + response: ResumeResponsePayload, + orgWriteAccess: Schema.Literals(["allowed", "denied"]), +}); +const decodeBrowserApprovalDecision = Schema.decodeUnknownOption(BrowserApprovalDecisionStorage); type JsonRpcRequestId = string | number; const JsonRpcRequestWithId = Schema.Struct({ @@ -312,8 +342,8 @@ export abstract class McpAgentSessionDOBase< private onStartPromise: Promise | null = null; private lastActivityMs = 0; private resolvedSessionName: string | undefined = undefined; - private approvalResponses = new Map(); - private approvalWaiters = new Map>(); + private approvalResponses = new Map(); + private approvalWaiters = new Map>(); private pendingApprovalLeases = new Map(); protected abstract openSessionDb(): TDbHandle | Promise; @@ -609,9 +639,29 @@ export abstract class McpAgentSessionDOBase< // the field. Their stored meta has no `resource`, and every such session // was minted against the default `/mcp` endpoint, so default it here // rather than let owner validation read `.kind` off undefined. - this.sessionMeta = stored - ? { ...stored, resource: stored.resource ?? defaultMcpResource } - : null; + if (!stored) { + this.sessionMeta = null; + return this.sessionMeta; + } + + if (stored.orgRoleModel === undefined) { + // Records written before the role-model field existed cannot prove + // that their optional role was derived under an enforcing host. Treat + // them as an organization-role session with no role, which denies + // workspace writes until a live request refreshes the metadata. + const { orgRole: _untrustedLegacyRole, ...legacy } = stored; + this.sessionMeta = { + ...legacy, + orgRoleModel: "organization", + resource: stored.resource ?? defaultMcpResource, + }; + return this.sessionMeta; + } + + this.sessionMeta = { + ...stored, + resource: stored.resource ?? defaultMcpResource, + }; return this.sessionMeta; }).pipe(Effect.withSpan("mcp.session.load_meta")); } @@ -1362,12 +1412,14 @@ export abstract class McpAgentSessionDOBase< // of starting one against half-torn-down state. await this.disposingRuntime; } - if (this.initialized) return; const props = isSessionProps(this.props) ? this.props : null; if (!props) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: McpAgent.init is a Promise-only framework hook and props are required before any Effect runtime exists. throw new Error("MCP session props are required"); } + if (this.initialized) { + return; + } const self = this; const program = Effect.gen(function* () { yield* self.prepareErrorCaptureScope(); @@ -1389,8 +1441,8 @@ export abstract class McpAgentSessionDOBase< // acquisition instead. const { dbHandle, mcpServer, engine } = yield* Effect.gen(function* () { const dbHandle = yield* self.openSessionDbHandle(); - const { mcpServer, engine } = yield* self.buildRuntime(sessionMeta, dbHandle); - return { dbHandle, mcpServer, engine }; + const built = yield* self.buildRuntime(sessionMeta, dbHandle); + return { dbHandle, ...built }; }); self.dbHandle = dbHandle; self.server = mcpServer; @@ -1670,7 +1722,7 @@ export abstract class McpAgentSessionDOBase< async resumeExecutionForApproval( executionId: string, - identity: McpApprovalOwner, + identity: McpApprovalPrincipal, response: ResumeResponse, incoming?: IncomingTraceHeaders, ): Promise { @@ -1687,7 +1739,10 @@ export abstract class McpAgentSessionDOBase< const paused = yield* self.engine.getPausedExecution(executionId); if (!paused) return { status: "not_found" } as const; - yield* self.recordApprovalResponse(executionId, response); + yield* self.recordApprovalResponse(executionId, { + response, + orgWriteAccess: identity.orgRole === "admin" ? "allowed" : "denied", + }); return resumeApprovalResult(executionId, response); }).pipe( Effect.withSpan("McpSessionDO.resumeExecutionForApproval", { @@ -2073,14 +2128,14 @@ export abstract class McpAgentSessionDOBase< private recordApprovalResponse( executionId: string, - response: ResumeResponse, + decision: BrowserApprovalDecision, ): Effect.Effect { const self = this; return Effect.gen(function* () { - self.approvalResponses.set(executionId, response); - yield* Effect.promise(() => self.ctx.storage.put(approvalResponseKey(executionId), response)); + self.approvalResponses.set(executionId, decision); + yield* Effect.promise(() => self.ctx.storage.put(approvalResponseKey(executionId), decision)); const waiter = self.approvalWaiters.get(executionId); - if (waiter) yield* Deferred.succeed(waiter, response); + if (waiter) yield* Deferred.succeed(waiter, decision); }); } @@ -2102,7 +2157,10 @@ export abstract class McpAgentSessionDOBase< yield* Effect.sync(() => { console.info(JSON.stringify({ event: "mcp_pending_approval_lease_expire", executionId })); }); - yield* self.recordApprovalResponse(executionId, response); + yield* self.recordApprovalResponse(executionId, { + response, + orgWriteAccess: "denied", + }); if (self.engine && !self.approvalWaiters.has(executionId)) { yield* self.engine.resume(executionId, response).pipe(Effect.ignore); } @@ -2114,7 +2172,7 @@ export abstract class McpAgentSessionDOBase< ); } - private takeApprovalResponse(executionId: string): Effect.Effect { + private takeApprovalResponse(executionId: string): Effect.Effect { const self = this; return Effect.promise(async () => { const memoryResponse = self.approvalResponses.get(executionId); @@ -2123,23 +2181,28 @@ export abstract class McpAgentSessionDOBase< await self.ctx.storage.delete(approvalResponseKey(executionId)); return memoryResponse; } - const stored = await self.ctx.storage.get(approvalResponseKey(executionId)); + const stored = await self.ctx.storage.get(approvalResponseKey(executionId)); if (!stored) return null; await self.ctx.storage.delete(approvalResponseKey(executionId)); - return stored; + const decision = Option.getOrNull(decodeBrowserApprovalDecision(stored)); + if (decision) return decision; + const legacyResponse = decodeResumeResponse(stored); + return legacyResponse ? { response: legacyResponse, orgWriteAccess: "denied" } : null; }); } - private waitForApprovalResponse(executionId: string): Effect.Effect { + private waitForApprovalResponse( + executionId: string, + ): Effect.Effect { const self = this; return Effect.gen(function* () { const existing = yield* self.takeApprovalResponse(executionId); if (existing) return existing; const waiter = - self.approvalWaiters.get(executionId) ?? (yield* Deferred.make()); + self.approvalWaiters.get(executionId) ?? (yield* Deferred.make()); self.approvalWaiters.set(executionId, waiter); - yield* Deferred.await(waiter).pipe( + const decision = yield* Deferred.await(waiter).pipe( Effect.ensuring( Effect.sync(() => { if (self.approvalWaiters.get(executionId) === waiter) { @@ -2148,7 +2211,8 @@ export abstract class McpAgentSessionDOBase< }), ), ); - return yield* self.takeApprovalResponse(executionId); + yield* self.takeApprovalResponse(executionId); + return decision; }); } diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts index 6877b2486a..8a95011ed4 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts @@ -272,6 +272,7 @@ const makeEngine = ( const sessionMeta = (input?: Partial): SessionMeta => ({ organizationId: "org_1", organizationName: "Test Org", + orgRoleModel: "organization", userId: "acct_1", elicitationMode: "model", resource: defaultMcpResource, diff --git a/packages/hosts/cloudflare/src/mcp/role-metadata.ts b/packages/hosts/cloudflare/src/mcp/role-metadata.ts new file mode 100644 index 0000000000..b914f66374 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/role-metadata.ts @@ -0,0 +1,21 @@ +export type OrgRoleMetadata = + | { + readonly orgRoleModel: "organization"; + readonly orgRole?: "admin" | "member"; + } + | { + readonly orgRoleModel: "none"; + readonly orgRole?: never; + }; + +/** Project a role source into the serializable discriminated union. */ +export const sessionOrgRoleMetadata = (source: { + readonly orgRoleModel: "organization" | "none"; + readonly orgRole?: "admin" | "member"; +}): OrgRoleMetadata => + source.orgRoleModel === "none" + ? { orgRoleModel: "none" } + : { + orgRoleModel: "organization", + ...(source.orgRole === undefined ? {} : { orgRole: source.orgRole }), + }; diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.ts b/packages/hosts/cloudflare/src/mcp/session-stub.ts index 2e17f3a412..125e4e935d 100644 --- a/packages/hosts/cloudflare/src/mcp/session-stub.ts +++ b/packages/hosts/cloudflare/src/mcp/session-stub.ts @@ -3,6 +3,7 @@ import type { ResumeResponse } from "@executor-js/execution"; import type { IncomingTraceHeaders, McpApprovalOwner, + McpApprovalPrincipal, McpSessionApprovalResult, McpSessionModelResumeResult, McpSessionResumeApprovalResult, @@ -26,7 +27,7 @@ export interface McpSessionStub { ) => Promise; readonly resumeExecutionForApproval: ( executionId: string, - identity: McpApprovalOwner, + identity: McpApprovalPrincipal, response: ResumeResponse, incoming?: IncomingTraceHeaders, ) => Promise; diff --git a/packages/hosts/mcp/src/browser-approval-store.ts b/packages/hosts/mcp/src/browser-approval-store.ts index 812b7c49e9..4949594e64 100644 --- a/packages/hosts/mcp/src/browser-approval-store.ts +++ b/packages/hosts/mcp/src/browser-approval-store.ts @@ -14,54 +14,56 @@ import { Deferred, Effect } from "effect"; -import type { ResumeResponse } from "@executor-js/execution"; - -import type { BrowserApprovalStore } from "./tool-server"; +import type { BrowserApprovalDecision, BrowserApprovalStore } from "./tool-server"; export interface InProcessBrowserApprovalStore { /** The store the MCP server awaits a decision on (browser elicitation mode). */ readonly store: BrowserApprovalStore; /** Record a human's decision, waking any in-flight `waitForResponse`. */ - readonly recordResponse: (executionId: string, response: ResumeResponse) => Effect.Effect; + readonly recordResponse: ( + executionId: string, + decision: BrowserApprovalDecision, + ) => Effect.Effect; /** Drop a pending decision/waiter (e.g. when its session is torn down). */ readonly forget: (executionId: string) => void; } export const makeInProcessBrowserApprovalStore = (): InProcessBrowserApprovalStore => { - const responses = new Map(); - const waiters = new Map>(); + const responses = new Map(); + const waiters = new Map>(); - const take = (executionId: string): Effect.Effect => + const take = (executionId: string): Effect.Effect => Effect.sync(() => { const response = responses.get(executionId) ?? null; if (response) responses.delete(executionId); return response; }); - const waitFor = (executionId: string): Effect.Effect => + const waitFor = (executionId: string): Effect.Effect => Effect.gen(function* () { const existing = yield* take(executionId); if (existing) return existing; - const waiter = waiters.get(executionId) ?? (yield* Deferred.make()); + const waiter = waiters.get(executionId) ?? (yield* Deferred.make()); waiters.set(executionId, waiter); - yield* Deferred.await(waiter).pipe( + const decision = yield* Deferred.await(waiter).pipe( Effect.ensuring( Effect.sync(() => { if (waiters.get(executionId) === waiter) waiters.delete(executionId); }), ), ); - return yield* take(executionId); + yield* take(executionId); + return decision; }); return { store: { takeResponse: take, waitForResponse: waitFor }, - recordResponse: (executionId, response) => + recordResponse: (executionId, decision) => Effect.gen(function* () { - responses.set(executionId, response); + responses.set(executionId, decision); const waiter = waiters.get(executionId); - if (waiter) yield* Deferred.succeed(waiter, response); + if (waiter) yield* Deferred.succeed(waiter, decision); }), forget: (executionId) => { responses.delete(executionId); diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index bdd31aaa4e..072f98fe11 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -10,7 +10,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Layer, Ref } from "effect"; +import { Cause, Effect, Layer, Ref, Schema } from "effect"; import { HttpRouter, HttpServer } from "effect/unstable/http"; import { @@ -22,6 +22,8 @@ import { McpServingRoutes, McpDiscoveryRoutes, McpSessionStore, + orgWriteAccessForPrincipal, + Principal as PrincipalSchema, preInitializeMethodNotFound, type McpResource, type McpDispatchResult, @@ -38,8 +40,26 @@ const TEST_PRINCIPAL: Principal = { name: "Test", avatarUrl: null, roles: ["user"], + orgRoleModel: "none", }; +it("allows workspace writes only when a role-less host explicitly declares no model", () => { + expect(orgWriteAccessForPrincipal(TEST_PRINCIPAL)).toBe("allowed"); + expect( + orgWriteAccessForPrincipal({ + ...TEST_PRINCIPAL, + orgRoleModel: "organization", + }), + ).toBe("denied"); +}); + +it.effect("rejects a role on the no-role principal arm", () => + Schema.decodeUnknownEffect(PrincipalSchema)({ + ...TEST_PRINCIPAL, + orgRole: "member", + }).pipe(Effect.flip, Effect.asVoid), +); + /** An auth provider that authenticates everything (so dispatch is reached). */ const AuthProviderLive = Layer.succeed(McpAuthProvider)({ discoveryRoutes: [ diff --git a/packages/hosts/mcp/src/in-memory-session-store.test.ts b/packages/hosts/mcp/src/in-memory-session-store.test.ts index 45f7fe6157..9bfa914488 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.test.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, type Cause } from "effect"; import type { ExecutionEngine } from "@executor-js/execution"; +import { FormElicitation, ToolAddress, createExecutor } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; import { makeInMemoryMcpSessionStore, @@ -20,6 +22,7 @@ const TEST_PRINCIPAL: Principal = { name: "Test", avatarUrl: null, roles: ["user"], + orgRoleModel: "organization", }; it("preserves native elicitation mode when creating an in-memory MCP session", async () => { @@ -104,7 +107,12 @@ const makeLatchedTestEngine = (): { shutdowns += 1; }), }; - return { engine, started, release: () => openGate(), shutdowns: () => shutdowns }; + return { + engine, + started, + release: () => openGate(), + shutdowns: () => shutdowns, + }; }; // A long TTL keeps the sweep's own timer out of the way; the assertions drive @@ -115,10 +123,14 @@ const IDLE_TTL_MS = 60_000; type TestSessionStore = ReturnType; /** Open a session on `sessions` and return its minted id. */ -const openSession = async (sessions: TestSessionStore): Promise => { +const openSession = async ( + sessions: TestSessionStore, + principal: Principal = TEST_PRINCIPAL, + requestUrl = "https://executor.test/mcp", +): Promise => { const response = (await Effect.runPromise( sessions.store.dispatch({ - request: new Request("https://executor.test/mcp", { + request: new Request(requestUrl, { method: "POST", headers: { "content-type": "application/json", @@ -135,7 +147,7 @@ const openSession = async (sessions: TestSessionStore): Promise => { }, }), }), - principal: TEST_PRINCIPAL, + principal, resource: defaultMcpResource, sessionId: null, method: "POST", @@ -147,6 +159,255 @@ const openSession = async (sessions: TestSessionStore): Promise => { return sessionId; }; +it("keeps overlapping warm-session workspace writes bound to their request roles", async () => { + const executor = await Effect.runPromise( + createExecutor({ ...makeTestConfig(), orgWrites: "request" }), + ); + const started = new Map void>(); + const startedPromises = ["member", "admin"].map( + (name) => + new Promise((resolve) => { + started.set(name, resolve); + }), + ); + let releaseWrites: () => void = () => {}; + const writeGate = new Promise((resolve) => { + releaseWrites = resolve; + }); + const writePolicy = (pattern: string) => + Effect.promise(async () => { + started.get(pattern)?.(); + await writeGate; + }).pipe( + Effect.andThen(executor.policies.create({ owner: "org", pattern, action: "block" })), + Effect.map((policy) => ({ result: policy.pattern })), + ); + const engine: ExecutionEngine = { + ...makeIdleTestEngine(), + execute: writePolicy, + executeWithPause: (code) => + writePolicy(code).pipe(Effect.map((result) => ({ status: "completed" as const, result }))), + }; + const sessions = makeInMemoryMcpSessionStore(() => + createExecutorMcpServer({ engine }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))), + ); + const admin = { ...TEST_PRINCIPAL, orgRole: "admin" as const }; + const sessionId = await openSession(sessions, admin); + const call = (id: number, role: "admin" | "member") => + Effect.runPromise( + sessions.store.dispatch({ + request: new Request("https://executor.test/mcp", { + method: "POST", + headers: { ...MCP_POST_HEADERS, "mcp-session-id": sessionId }, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name: "execute", arguments: { code: role } }, + }), + }), + principal: { ...admin, orgRole: role }, + resource: defaultMcpResource, + sessionId, + method: "POST", + }), + ) as Promise; + + // Start the demoted member first, then let a stale admin request overlap it. + // A session-global cell ends this interleaving at "allowed" and incorrectly + // lets both sinks commit; request-local bindings keep the member denied. + const memberCall = call(2, "member"); + await startedPromises[0]; + const adminCall = call(3, "admin"); + await startedPromises[1]; + releaseWrites(); + + const [memberResponse, adminResponse] = await Promise.all([memberCall, adminCall]); + const memberBody = (await memberResponse.json()) as { + result?: { isError?: boolean }; + }; + const adminBody = (await adminResponse.json()) as { + result?: { isError?: boolean }; + }; + expect(memberBody.result?.isError).toBe(true); + expect(adminBody.result?.isError).not.toBe(true); + const policies = await Effect.runPromise(executor.policies.list()); + expect(policies.map((policy) => policy.pattern)).toEqual(["admin"]); + + await sessions.close(); + await Effect.runPromise(executor.close()); +}); + +it("binds a paused workspace write to the resuming principal after demotion", async () => { + const executor = await Effect.runPromise( + createExecutor({ ...makeTestConfig(), orgWrites: "request" }), + ); + const executionId = "exec_resume_demotion"; + const pattern = "paused-resume-demotion.*"; + const engine: ExecutionEngine = { + ...makeIdleTestEngine(), + executeWithPause: () => + Effect.succeed({ + status: "paused", + execution: { + id: executionId, + elicitationContext: { + address: ToolAddress.make("executor.coreTools.policies.create"), + args: { owner: "org", pattern, action: "block" }, + request: FormElicitation.make({ + message: "Approve?", + requestedSchema: {}, + }), + }, + }, + }), + resume: () => + executor.policies.create({ owner: "org", pattern, action: "block" }).pipe( + Effect.map((policy) => ({ + status: "completed", + result: { result: policy }, + })), + ), + }; + const sessions = makeInMemoryMcpSessionStore(() => + createExecutorMcpServer({ engine }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))), + ); + const admin = { ...TEST_PRINCIPAL, orgRole: "admin" as const }; + const sessionId = await openSession(sessions, admin); + const call = (id: number, principal: Principal, name: "execute" | "resume", args: unknown) => + Effect.runPromise( + sessions.store.dispatch({ + request: new Request("https://executor.test/mcp", { + method: "POST", + headers: { ...MCP_POST_HEADERS, "mcp-session-id": sessionId }, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name, arguments: args }, + }), + }), + principal, + resource: defaultMcpResource, + sessionId, + method: "POST", + }), + ) as Promise; + + const paused = await call(2, admin, "execute", { + code: "create workspace policy", + }); + expect(paused.status).toBe(200); + const demoted = { ...admin, orgRole: "member" as const }; + const resumed = await call(3, demoted, "resume", { + executionId, + action: "accept", + }); + const body = (await resumed.json()) as { result?: { isError?: boolean } }; + expect(body.result?.isError).toBe(true); + expect(await Effect.runPromise(executor.policies.list())).toEqual([]); + + await sessions.close(); + await Effect.runPromise(executor.close()); +}); + +it("uses the browser approver's demoted role after an admin starts waiting", async () => { + const executor = await Effect.runPromise( + createExecutor({ ...makeTestConfig({ coreTools: {} }), orgWrites: "request" }), + ); + const executionId = "exec_browser_resume_demotion"; + const pattern = "browser-resume-demotion.*"; + const pausedExecution = { + id: executionId, + elicitationContext: { + address: ToolAddress.make("executor.coreTools.policies.create"), + args: { owner: "org", pattern, action: "block" }, + request: FormElicitation.make({ message: "Approve?", requestedSchema: {} }), + }, + }; + const engine: ExecutionEngine = { + ...makeIdleTestEngine(), + executeWithPause: () => + Effect.succeed({ status: "paused" as const, execution: pausedExecution }), + getPausedExecution: (id) => Effect.succeed(id === executionId ? pausedExecution : null), + resume: (id) => + id === executionId + ? executor.policies.create({ owner: "org", pattern, action: "block" }).pipe( + Effect.map((policy) => ({ + status: "completed" as const, + result: { result: policy }, + })), + ) + : Effect.succeed(null), + }; + const sessions = makeInMemoryMcpSessionStore((_principal, options) => + createExecutorMcpServer({ engine, ...options }).pipe( + Effect.map((mcpServer) => ({ mcpServer, engine })), + ), + ); + const admin = { ...TEST_PRINCIPAL, orgRole: "admin" as const }; + const member = { ...admin, orgRole: "member" as const }; + const sessionId = await openSession( + sessions, + admin, + "https://executor.test/mcp?elicitation_mode=browser", + ); + + const call = (id: number, name: "execute" | "resume", args: unknown) => + Effect.runPromise( + sessions.store.dispatch({ + request: new Request("https://executor.test/mcp", { + method: "POST", + headers: { ...MCP_POST_HEADERS, "mcp-session-id": sessionId }, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name, arguments: args }, + }), + }), + principal: admin, + resource: defaultMcpResource, + sessionId, + method: "POST", + }), + ) as Promise; + + const pausedResponse = await call(2, "execute", { code: "create workspace policy" }); + const pausedBody = (await pausedResponse.json()) as { + result?: { structuredContent?: { executionId?: string } }; + }; + const pausedExecutionId = pausedBody.result?.structuredContent?.executionId; + expect(pausedExecutionId).toBe(executionId); + if (!pausedExecutionId) return; + + const firstResume = call(3, "resume", { executionId: pausedExecutionId }); + await Promise.resolve(); + await Promise.resolve(); + + const approvalResponse = await sessions.handleApprovalRequest( + new Request( + `https://executor.test/api/mcp-sessions/${sessionId}/executions/${pausedExecutionId}/resume`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "accept", content: {} }), + }, + ), + member, + ); + expect(approvalResponse?.status).toBe(200); + + const resumeBody = (await (await firstResume).json()) as { + result?: { isError?: boolean }; + }; + expect(resumeBody.result?.isError).toBe(true); + expect(await Effect.runPromise(executor.policies.list())).toEqual([]); + + await sessions.close(); + await Effect.runPromise(executor.close()); +}); + it("evicts a session that goes idle past the TTL and keeps a busy one", async () => { const engine = makeIdleTestEngine(); const sessions = makeInMemoryMcpSessionStore( @@ -316,7 +577,10 @@ const makeServingStore = () => { Effect.flatMap(() => createExecutorMcpServer({ engine: stubEngine })), Effect.map((mcpServer) => ({ mcpServer, engine: stubEngine })), ); - return { sessions: makeInMemoryMcpSessionStore(buildServer), buildCount: (): number => builds }; + return { + sessions: makeInMemoryMcpSessionStore(buildServer), + buildCount: (): number => builds, + }; }; const dispatchPost = ( diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index d66d30d84d..af9e149741 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -3,6 +3,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; +import type { OrgWriteAccess } from "@executor-js/sdk"; import { buildResumeApprovalUrl, @@ -19,9 +20,12 @@ import { import { jsonRpcErrorBody, preInitializeMethodNotFound } from "./envelope"; import { McpSessionStore, + MCP_ORG_WRITE_ACCESS_HEADER, defaultMcpResource, mcpResourceKey, + orgWriteAccessForPrincipal, principalOwns, + withOrgWriteAccess, type McpDispatchInput, type McpDispatchResult, type Principal, @@ -309,17 +313,44 @@ export const makeInMemoryMcpSessionStore = ( /** * Drive a transport for one web request, recovering any defect to a 500. On a * fresh transport that never minted a session id (e.g. a non-initialize first - * request), close it and its server eagerly so they don't leak. + * request), close it and its server eagerly so they don't leak. The SDK + * transport rejects malformed or literal-null POST bodies before dispatch; + * every request that reaches dispatch has its org-write-access header + * overwritten below with the value derived from the authenticated principal. */ const runHandleRequest = ( transport: WebStandardStreamableHTTPServerTransport, request: Request, + orgWriteAccess: OrgWriteAccess, onClose?: () => void, ): Effect.Effect => { const finish = (): void => { if (onClose && !transport.sessionId) onClose(); }; - return Effect.promise(() => transport.handleRequest(request)).pipe( + const handle = + request.method === "POST" + ? Effect.tryPromise({ + try: () => request.json(), + catch: () => null, + }).pipe( + Effect.orElseSucceed(() => null), + Effect.flatMap((parsedBody) => { + if (parsedBody === null) + return Effect.promise(() => transport.handleRequest(request)); + const headers = new Headers(request.headers); + headers.set(MCP_ORG_WRITE_ACCESS_HEADER, orgWriteAccess); + const bodylessRequest = new Request(request.url, { + method: request.method, + headers, + signal: request.signal, + }); + return Effect.promise(() => transport.handleRequest(bodylessRequest, { parsedBody })); + }), + ) + : Effect.promise(() => + transport.handleRequest(withOrgWriteAccess(request, orgWriteAccess)), + ); + return handle.pipe( Effect.tap(() => Effect.sync(finish)), Effect.catchCause((cause) => Effect.sync(() => { @@ -342,13 +373,14 @@ export const makeInMemoryMcpSessionStore = ( const owner = owners.get(sessionId); if (!transport || !owner) return Effect.succeed("not-found"); if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden"); + owners.set(sessionId, { principal, resource }); touch(sessionId); // Claim before the await, release in the finalizer — `runHandleRequest` // already recovers every failure to a 500, but `ensuring` also covers an // interrupt, so the counter cannot be left permanently raised (which would // make the session immortal, the opposite leak). beginRequest(sessionId); - return runHandleRequest(transport, request).pipe( + return runHandleRequest(transport, request, orgWriteAccessForPrincipal(principal)).pipe( Effect.ensuring(Effect.sync(() => endRequest(sessionId))), ); }; @@ -420,14 +452,19 @@ export const makeInMemoryMcpSessionStore = ( yield* Effect.promise(() => mcpServer.connect(transport)); // The session id is minted on the first (initialize) request, so we // drive `handleRequest` here; if no id results we close eagerly. - return yield* runHandleRequest(transport, request, () => { - // Nothing was ever registered under a session id, so `dispose` has - // no entry to work from — release the three handles by hand, engine - // included. - void ignoreClose(null, "transport", () => transport.close()); - void ignoreClose(null, "server", () => mcpServer.close()); - void shutdownEngine(null, engine); - }); + return yield* runHandleRequest( + transport, + request, + orgWriteAccessForPrincipal(principal), + () => { + // Nothing was ever registered under a session id, so `dispose` has + // no entry to work from — release the three handles by hand, engine + // included. + void ignoreClose(null, "transport", () => transport.close()); + void ignoreClose(null, "server", () => mcpServer.close()); + void shutdownEngine(null, engine); + }, + ); }), ), // A build failure has nowhere typed to go in the envelope; render a 500. @@ -532,7 +569,12 @@ export const makeInMemoryMcpSessionStore = ( const response = raw === null ? null : decodeResumeResponse(raw); if (!response) return json({ error: "Invalid approval response" }, 400); - await Effect.runPromise(approvals.recordResponse(executionId, response)); + await Effect.runPromise( + approvals.recordResponse(executionId, { + response, + orgWriteAccess: principal ? orgWriteAccessForPrincipal(principal) : "allowed", + }), + ); return json({ status: "completed", ...formatResumeAcknowledgement(executionId, response), diff --git a/packages/hosts/mcp/src/index.ts b/packages/hosts/mcp/src/index.ts index c36fc10987..d5574977df 100644 --- a/packages/hosts/mcp/src/index.ts +++ b/packages/hosts/mcp/src/index.ts @@ -21,6 +21,9 @@ export { defaultMcpResource, mcpResourceKey, principalOwns, + orgWriteAccessForPrincipal, + withOrgWriteAccess, + MCP_ORG_WRITE_ACCESS_HEADER, authenticated, unauthorized, forbidden, diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index 12e713dd91..01d0994db0 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -1,6 +1,8 @@ import { Context, Effect, Layer, Schema } from "effect"; import type { Cause } from "effect"; +import type { OrgWriteAccess } from "@executor-js/sdk"; + // --------------------------------------------------------------------------- // Provider-neutral MCP serving seams. // @@ -36,7 +38,7 @@ import type { Cause } from "effect"; // (provider) and serving (envelope). // --------------------------------------------------------------------------- -export const Principal = Schema.Struct({ +const PrincipalFields = { accountId: Schema.String, organizationId: Schema.String, organizationName: Schema.String, @@ -48,10 +50,43 @@ export const Principal = Schema.Struct({ name: Schema.NullOr(Schema.String), avatarUrl: Schema.NullOr(Schema.String), roles: Schema.Array(Schema.String), -}); +} as const; + +export const Principal = Schema.Union([ + Schema.Struct({ + ...PrincipalFields, + orgRoleModel: Schema.Literal("organization"), + /** Missing at a legacy boundary fails closed. */ + orgRole: Schema.optional(Schema.Literals(["admin", "member"])), + }), + Schema.Struct({ + ...PrincipalFields, + orgRoleModel: Schema.Literal("none"), + /** Reject contradictory role-bearing values on a role-less host. */ + orgRole: Schema.optional(Schema.Never), + }), +]); export type Principal = Schema.Schema.Type; +/** Internal header overwritten by a trusted session store before MCP dispatch. */ +export const MCP_ORG_WRITE_ACCESS_HEADER = "x-executor-org-write-access"; + +/** Derive the effective workspace-write access for one authenticated request. */ +export const orgWriteAccessForPrincipal = (principal: Principal): OrgWriteAccess => + principal.orgRoleModel === "none" || principal.orgRole === "admin" ? "allowed" : "denied"; + +/** + * Stamp request-bound workspace-write access for the MCP SDK request handler. + * Any client-supplied value is overwritten before the request reaches the + * transport. + */ +export const withOrgWriteAccess = (request: Request, access: OrgWriteAccess): Request => { + const headers = new Headers(request.headers); + headers.set(MCP_ORG_WRITE_ACCESS_HEADER, access); + return new Request(request, { headers }); +}; + /** Ownership is keyed on (accountId, organizationId) — a subset of the principal. */ export const principalOwns = (owner: Principal, principal: Principal): boolean => owner.accountId === principal.accountId && owner.organizationId === principal.organizationId; diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index f355fc1649..1b7bb8aa45 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -1111,9 +1111,18 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }); it("browser approval mode consumes a user-approved response and returns the resumed result", async () => { - const approved = new Map }>(); + const approved = new Map< + string, + { + response: { action: "accept"; content?: Record }; + orgWriteAccess: "allowed"; + } + >(); const waiter = await Effect.runPromise( - Deferred.make<{ action: "accept"; content?: Record }>(), + Deferred.make<{ + response: { action: "accept"; content?: Record }; + orgWriteAccess: "allowed"; + }>(), ); const engine = makeStubEngine({ resume: (executionId, response) => @@ -1132,9 +1141,12 @@ describe("MCP host server — client without elicitation (pause/resume)", () => name: "resume", arguments: { executionId: "exec_1" }, }); - const response = { action: "accept" as const, content: {} }; - approved.set("exec_1", response); - await Effect.runPromise(Deferred.succeed(waiter, response)); + const decision = { + response: { action: "accept" as const, content: {} }, + orgWriteAccess: "allowed" as const, + }; + approved.set("exec_1", decision); + await Effect.runPromise(Deferred.succeed(waiter, decision)); const resumed = await waiting; expect(resumed.content).toEqual([{ type: "text", text: "resumed-after-browser" }]); expect(resumed.structuredContent).toMatchObject({ @@ -1967,7 +1979,11 @@ describe("MCP host server — hang-visibility tracing", () => { { elicitationMode: { mode: "browser", approvalUrl: (id) => `/approve/${id}` }, browserApprovalStore: { - takeResponse: () => Effect.succeed({ action: "accept" as const }), + takeResponse: () => + Effect.succeed({ + response: { action: "accept" as const }, + orgWriteAccess: "allowed" as const, + }), }, }, ); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index f8c6a908b9..730c5bfd89 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -20,7 +20,13 @@ import type { import { Validator } from "@cfworker/json-schema"; import * as z from "zod/v4"; -import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk"; +import { + CurrentOrgWriteAccess, + isToolFile, + makeOrgWriteAccessState, + sanitizeArtifactPreviewMarkup, + type OrgWriteAccess, +} from "@executor-js/sdk"; import type { Artifact, ArtifactBinding, @@ -67,6 +73,7 @@ import { resolveArtifactBindings, type BindableConnection, } from "./artifact-bindings"; +import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams"; // --------------------------------------------------------------------------- // Workers-compatible JSON Schema validator (replaces Ajv which uses new Function()) @@ -272,10 +279,16 @@ export type ExecutorMcpServerConfig; readonly stateless: true } & SharedMcpServerConfig); export type BrowserApprovalStore = { - readonly takeResponse: (executionId: string) => Effect.Effect; - readonly waitForResponse?: (executionId: string) => Effect.Effect; + readonly takeResponse: (executionId: string) => Effect.Effect; + readonly waitForResponse?: (executionId: string) => Effect.Effect; }; +/** Browser response paired with authorization derived from the deciding user. */ +export interface BrowserApprovalDecision { + readonly response: ResumeResponse; + readonly orgWriteAccess: OrgWriteAccess; +} + export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000; const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000; @@ -859,8 +872,14 @@ const extractInventory = (description: string): string => { type McpRequestJoinKeys = { readonly requestId: string | number; readonly sessionId?: string | undefined; + readonly requestInfo?: { + readonly headers: Readonly>; + }; }; +const requestOrgWriteAccess = (extra: McpRequestJoinKeys): OrgWriteAccess => + extra.requestInfo?.headers[MCP_ORG_WRITE_ACCESS_HEADER] === "allowed" ? "allowed" : "denied"; + // `mcp.request.session_id` is emitted unconditionally (empty string when the // transport carries none) to match the worker-side `annotateMcpRequest` // producer: JSON-RPC ids are small per-session integers, so a row without the @@ -1183,9 +1202,16 @@ export const createExecutorMcpServer = ( const parent = resolveParentSpan(); return parent ? Effect.withParentSpan(effect, parent) : effect; }; - const runToolEffect = (effect: Effect.Effect) => + const runToolEffect = ( + effect: Effect.Effect, + extra: McpRequestJoinKeys, + ) => Effect.runPromiseWith(context)( anchor(effect).pipe( + Effect.provideService( + CurrentOrgWriteAccess, + makeOrgWriteAccessState(requestOrgWriteAccess(extra)), + ), Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))), ), ); @@ -1458,13 +1484,13 @@ export const createExecutorMcpServer = ( const takeBrowserApprovalResponse = ( executionId: string, - ): Effect.Effect => { + ): Effect.Effect => { return config.browserApprovalStore?.takeResponse(executionId) ?? Effect.succeed(null); }; const waitForBrowserApprovalResponse = ( executionId: string, - ): Effect.Effect => { + ): Effect.Effect => { const waitForResponse = config.browserApprovalStore?.waitForResponse; if (!waitForResponse) return takeBrowserApprovalResponse(executionId); @@ -1485,10 +1511,15 @@ export const createExecutorMcpServer = ( "mcp.tool.name": "resume", "mcp.execute.execution_id": executionId, }); - const response = yield* waitForBrowserApprovalResponse(executionId); - if (!response) return yield* requireUserResumeApproval(executionId); + const decision = yield* waitForBrowserApprovalResponse(executionId); + if (!decision) return yield* requireUserResumeApproval(executionId); - const outcome = yield* resumeWithLifecycle(executionId, response); + const outcome = yield* resumeWithLifecycle(executionId, decision.response).pipe( + Effect.provideService( + CurrentOrgWriteAccess, + makeOrgWriteAccessState(decision.orgWriteAccess), + ), + ); if (!outcome) { return missingExecutionResult(executionId); } @@ -1523,7 +1554,7 @@ export const createExecutorMcpServer = ( description, inputSchema: { code: z.string().trim().min(1) }, }, - ({ code }, extra) => runToolEffect(executeCode(code, extra)), + ({ code }, extra) => runToolEffect(executeCode(code, extra), extra), ), ).pipe( Effect.withSpan("mcp.host.register_tool", { @@ -1550,8 +1581,8 @@ export const createExecutorMcpServer = ( ), }, }, - ({ name }) => - runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))), + ({ name }, extra) => + runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra), ), ).pipe( Effect.withSpan("mcp.host.register_tool", { @@ -1586,6 +1617,7 @@ export const createExecutorMcpServer = ( ({ executionId, action, content: rawContent }, extra) => runToolEffect( resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + extra, ), ); } @@ -1602,7 +1634,8 @@ export const createExecutorMcpServer = ( executionId: z.string().describe("The execution ID from the paused result"), }, }, - ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)), + ({ executionId }, extra) => + runToolEffect(resumeAfterBrowserApproval(executionId, extra), extra), ); }).pipe( Effect.withSpan("mcp.host.register_tool", { @@ -1653,6 +1686,7 @@ export const createExecutorMcpServer = ( }, }), ), + extra, ), ); } @@ -2104,8 +2138,11 @@ export const createExecutorMcpServer = ( ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, }, }, - ({ code, title, description, connections, artifactId }) => - runToolEffect(createArtifact({ code, title, description, connections, artifactId })), + ({ code, title, description, connections, artifactId }, extra) => + runToolEffect( + createArtifact({ code, title, description, connections, artifactId }), + extra, + ), ), ).pipe( Effect.withSpan("mcp.host.register_tool", { @@ -2170,8 +2207,11 @@ export const createExecutorMcpServer = ( ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, }, }, - ({ artifactId, edits, connections, title, description }) => - runToolEffect(editArtifact({ artifactId, edits, connections, title, description })), + ({ artifactId, edits, connections, title, description }, extra) => + runToolEffect( + editArtifact({ artifactId, edits, connections, title, description }), + extra, + ), ), ).pipe( Effect.withSpan("mcp.host.register_tool", { @@ -2189,7 +2229,7 @@ export const createExecutorMcpServer = ( ].join("\n"), inputSchema: {}, }, - () => runToolEffect(listArtifacts()), + (_args, extra) => runToolEffect(listArtifacts(), extra), ), ).pipe( Effect.withSpan("mcp.host.register_tool", { @@ -2214,7 +2254,7 @@ export const createExecutorMcpServer = ( ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, }, }, - ({ id }) => runToolEffect(showArtifact(id)), + ({ id }, extra) => runToolEffect(showArtifact(id), extra), ), ).pipe( Effect.withSpan("mcp.host.register_tool", { @@ -2245,7 +2285,7 @@ export const createExecutorMcpServer = ( }, }, ({ code, artifactId }, extra) => - runToolEffect(executeCodeFromApp(code, artifactId, extra)), + runToolEffect(executeCodeFromApp(code, artifactId, extra), extra), ); executeActionResumeTool = registerAppTool( @@ -2270,6 +2310,7 @@ export const createExecutorMcpServer = ( ({ executionId, action, content: rawContent }, extra) => runToolEffect( resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + extra, ), ); }).pipe( diff --git a/packages/plugins/file-secrets/src/data-dir.test.ts b/packages/plugins/file-secrets/src/data-dir.test.ts index 27e5013939..303983a2d2 100644 --- a/packages/plugins/file-secrets/src/data-dir.test.ts +++ b/packages/plugins/file-secrets/src/data-dir.test.ts @@ -83,9 +83,11 @@ describe("file secrets data directory", () => { const authPath = first.executor.fileSecrets.filePath; expect(authPath).toBe(join(dataDir, "auth.json")); expect(existsSync(authPath)).toBe(true); - expect(readFileSync(authPath, "utf8")).toContain( - '"connection:org:durable-secrets:main:token": "secret-token"', + const storedCredentials = readFileSync(authPath, "utf8"); + expect(storedCredentials).toMatch( + /"connection:org:durable-secrets:main:[^"]+:token": "secret-token"/, ); + expect(storedCredentials).not.toContain('"connection:org:durable-secrets:main:token"'); expect(existsSync(join(dataDir, "test.db"))).toBe(true); return authPath; }), diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 363038ef5c..d5fced917a 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -1,6 +1,10 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared"; +import { + InternalError, + IntegrationAlreadyExistsError, + OrgWriteDeniedError, +} from "@executor-js/sdk/shared"; import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; import { GraphqlAuthMethod, GraphqlAuthMethodInput } from "../sdk/types"; @@ -87,6 +91,7 @@ const GraphqlErrors = [ IntrospectionError, ExtractionError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ] as const; export const GraphqlGroup = HttpApiGroup.make("graphql") diff --git a/packages/plugins/graphql/src/sdk/describe-auth-methods.test.ts b/packages/plugins/graphql/src/sdk/describe-auth-methods.test.ts index 9fdd1aa54d..37a9cb615d 100644 --- a/packages/plugins/graphql/src/sdk/describe-auth-methods.test.ts +++ b/packages/plugins/graphql/src/sdk/describe-auth-methods.test.ts @@ -164,7 +164,7 @@ describe("describeGraphqlAuthMethods", () => { expect(methods.map((m) => m.id)).toEqual(["a", "b"]); }); - it("returns [] when no auth methods are declared", () => { + it("projects an empty auth method list as no-auth", () => { const methods = describeGraphqlAuthMethods( recordWith({ endpoint: "https://x.example/graphql", @@ -172,7 +172,14 @@ describe("describeGraphqlAuthMethods", () => { authenticationTemplate: [], }), ); - expect(methods).toEqual([]); + expect(methods).toEqual([ + { + id: "none", + label: "No authentication", + kind: "none", + template: "none", + }, + ]); }); it("returns [] for a malformed / foreign config blob", () => { diff --git a/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts b/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts index 567b865a6c..1db71b948e 100644 --- a/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts +++ b/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts @@ -81,7 +81,7 @@ describe("GraphQL invocation timeout", () => { name: ConnectionName.make("main"), integration: IntegrationSlug.make("invocation_timeout"), template: AuthTemplateSlug.make("none"), - value: "unused", + values: {}, }); const startedAt = Date.now(); diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 8f48e8e606..45290fbfec 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -17,6 +17,7 @@ import { ToolAddress, createExecutor, endpointForTelemetry, + makeInMemoryBlobStore, } from "@executor-js/sdk"; import { makeTestConfig, @@ -120,6 +121,21 @@ const makeExecutor = () => makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), ); +const recordingBlobStore = () => { + const base = makeInMemoryBlobStore(); + let writes = 0; + return { + store: { + ...base, + put: (namespace: string, key: string, value: string) => + Effect.sync(() => { + writes += 1; + }).pipe(Effect.andThen(base.put(namespace, key, value))), + }, + writeCount: () => writes, + }; +}; + const toolAddr = (integration: string, connection: string, tool: string): ToolAddress => ToolAddress.make(`tools.${integration}.org.${connection}.${tool}`); @@ -129,7 +145,7 @@ const createOrgConnection = ( readonly integration: string; readonly name: string; readonly template: string; - readonly value: string; + readonly value?: string; }, ) => executor.connections.create({ @@ -137,10 +153,33 @@ const createOrgConnection = ( name: ConnectionName.make(input.name), integration: IntegrationSlug.make(input.integration), template: AuthTemplateSlug.make(input.template), - value: input.value, + ...(input.value === undefined ? { values: {} } : { value: input.value }), }); describe("graphqlPlugin real protocol server", () => { + it.effect("denies member schema persistence before writing an org blob", () => + Effect.gen(function* () { + const blobs = recordingBlobStore(); + const config = makeTestConfig({ plugins: [graphqlPlugin()] as const }); + const member = yield* createExecutor({ + ...config, + blobs: blobs.store, + orgWrites: "denied", + }); + + const error = yield* member.graphql + .addIntegration({ + endpoint: "https://example.test/graphql", + slug: "denied-graphql", + introspectionJson, + }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "OrgWriteDeniedError" }); + expect(blobs.writeCount()).toBe(0); + }), + ); + it("uses query-free endpoints for invocation attributes", () => { expect(endpointForTelemetry("https://api.example.test/graphql?token=secret#section")).toBe( "https://api.example.test/graphql", @@ -311,7 +350,7 @@ describe("graphqlPlugin real protocol server", () => { integration: "live_graph", name: "default", template: "none", - value: "unused", + value: "", }); yield* waitForRecordedRequests(server.requests, (requests) => @@ -325,6 +364,44 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("rejects credential input for no-auth GraphQL and accepts an empty input map", () => + Effect.gen(function* () { + const server = yield* serveGreetingServer; + const executor = yield* makeExecutor(); + const integration = IntegrationSlug.make("no_auth_create"); + + yield* executor.graphql.addIntegration({ + endpoint: server.endpoint, + slug: String(integration), + }); + + const error = yield* executor.connections + .create({ + owner: "org", + name: ConnectionName.make("with-secret"), + integration, + template: AuthTemplateSlug.make("none"), + value: "must-not-be-stored", + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "InvalidConnectionInputError", + message: "A no-auth connection cannot accept credential inputs.", + }); + expect(yield* executor.connections.list({ integration })).toEqual([]); + + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("public"), + integration, + template: AuthTemplateSlug.make("none"), + values: {}, + }); + expect(String(connection.address)).toBe("tools.no_auth_create.org.public"); + expect(yield* executor.connections.list({ integration })).toHaveLength(1); + }), + ); + it.effect("uses the executor HttpClient layer for connection-time introspection", () => Effect.gen(function* () { const seen: string[] = []; @@ -356,7 +433,6 @@ describe("graphqlPlugin real protocol server", () => { integration: "guarded_graph", name: "default", template: "none", - value: "unused", }); const tools = yield* executor.tools.list(); @@ -420,7 +496,6 @@ describe("graphqlPlugin real protocol server", () => { integration: "named_ops", name: "main", template: "none", - value: "unused", }); yield* executor.execute(toolAddr("named_ops", "main", "query.hello"), { name: "Ada" }); @@ -462,7 +537,6 @@ describe("graphqlPlugin real protocol server", () => { integration: "http_error_graph", name: "main", template: "none", - value: "unused", }); const result = yield* executor.execute(toolAddr("http_error_graph", "main", "query.hello"), { @@ -508,7 +582,6 @@ describe("graphqlPlugin real protocol server", () => { integration: "auth_wall_graph", name: "main", template: "none", - value: "unused", }); const result = yield* executor.execute(toolAddr("auth_wall_graph", "main", "query.hello"), { @@ -551,7 +624,6 @@ describe("graphqlPlugin real protocol server", () => { integration: "scope_graph", name: "main", template: "none", - value: "unused", }); const result = yield* executor.execute(toolAddr("scope_graph", "main", "query.hello"), { @@ -600,7 +672,6 @@ describe("graphqlPlugin real protocol server", () => { integration: "scope_hdr_graph", name: "main", template: "none", - value: "unused", }); const result = yield* executor.execute(toolAddr("scope_hdr_graph", "main", "query.hello"), { @@ -962,7 +1033,6 @@ describe("graphqlPlugin real protocol server", () => { integration: "incomplete_sync", name: "main", template: "none", - value: "unused", }); const connection = yield* executor.connections.get({ @@ -1005,7 +1075,6 @@ describe("graphqlPlugin", () => { integration: "test_api", name: "main", template: "none", - value: "unused", }); const tools = yield* executor.tools.list(); @@ -1045,7 +1114,6 @@ describe("graphqlPlugin", () => { integration: "removable", name: "main", template: "none", - value: "unused", }); let tools = yield* executor.tools.list(); @@ -1099,7 +1167,6 @@ describe("graphqlPlugin", () => { integration: "approval_test", name: "main", template: "none", - value: "unused", }); const tools = yield* executor.tools.list(); @@ -1330,7 +1397,6 @@ describe("graphqlPlugin generates valid operations against rich schemas (#1146)" integration: slug, name: "main", template: "none", - value: "unused", }); yield* server.clearRequests; return { server, executor }; diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 4d52ffa5ca..176e6d7721 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -19,6 +19,7 @@ import { type HealthCheckResult, type IntegrationConfig, type IntegrationRecord, + type OrgWriteDeniedError, type PluginCtx, type StorageFailure, type ToolAnnotations, @@ -842,7 +843,9 @@ export const describeGraphqlAuthMethods = ( ): readonly AuthMethodDescriptor[] => { const config = Option.getOrUndefined(decodeGraphqlIntegrationConfigOption(record.config)); if (!config) return []; - return config.authenticationTemplate.map((method: GraphqlAuthMethod): AuthMethodDescriptor => { + const templates = config.authenticationTemplate; + if (templates.length === 0) return [describeNoneAuthMethod("none")]; + return templates.map((method: GraphqlAuthMethod): AuthMethodDescriptor => { if (method.kind === "apikey") return describeApiKeyAuthMethod(method); if (method.kind === "oauth2") { return { @@ -907,6 +910,7 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { return yield* new IntegrationAlreadyExistsError({ slug }); } + yield* ctx.core.integrations.authorizeWrite(); return yield* addIntegrationTransaction(input, slug); }); @@ -954,6 +958,7 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { introspectionHash, }); + yield* ctx.core.integrations.authorizeWrite(); yield* ctx.storage.putIntrospection(introspectionHash, snapshotJson); yield* ctx.transaction( @@ -1051,7 +1056,7 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { const configureAuthMethods = ( slug: string, input: GraphqlConfigureAuthInput, - ): Effect.Effect => + ): Effect.Effect => ctx.transaction( Effect.gen(function* () { const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 9d928a0e2d..c1c7a7a3ca 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,6 +4,7 @@ import { IntegrationSlug, InternalError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, } from "@executor-js/sdk/shared"; import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; @@ -243,6 +244,7 @@ export const McpGroup = HttpApiGroup.make("mcp") McpConnectionError, McpToolDiscoveryError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ], }), ) @@ -250,7 +252,7 @@ export const McpGroup = HttpApiGroup.make("mcp") HttpApiEndpoint.delete("removeServer", "/mcp/servers/:slug", { params: SlugParams, success: RemoveServerResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ) .add( @@ -265,7 +267,7 @@ export const McpGroup = HttpApiGroup.make("mcp") params: SlugParams, payload: ConfigureServerPayload, success: ConfigureServerResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ) .add( @@ -273,7 +275,7 @@ export const McpGroup = HttpApiGroup.make("mcp") params: SlugParams, payload: ConfigureAuthPayload, success: ConfigureAuthResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ) .add( diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index 13c2252afc..ad52292b70 100644 --- a/packages/plugins/mcp/src/react/McpSignInButton.tsx +++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx @@ -12,6 +12,7 @@ import { connectionsAllAtom } from "@executor-js/react/api/atoms"; import { AddAccountModal } from "@executor-js/react/components/add-account-modal"; import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; +import { useCanCreateWorkspaceConnections } from "@executor-js/react/multiplayer/use-admin-nav"; import { mcpServerAtom } from "./atoms"; import type { McpAuthMethod } from "../sdk/types"; @@ -34,6 +35,7 @@ export default function McpSignInButton(props: { integrationId: string; owner?: const serverResult = useAtomValue(mcpServerAtom(slug)); const connectionsResult = useAtomValue(connectionsAllAtom); const [modalOpen, setModalOpen] = useState(false); + const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null; const remote = server !== null && server.config.transport === "remote" ? server.config : null; @@ -77,7 +79,9 @@ export default function McpSignInButton(props: { integrationId: string; owner?: [modalOpen, oauthMethod, server, slug, targetOwner], ); - if (oauthMethod === null) return null; + if (oauthMethod === null || (targetOwner === "org" && !canCreateWorkspaceConnections)) { + return null; + } return ( <> diff --git a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts index cb592e91b3..6409fd931f 100644 --- a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +++ b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts @@ -225,11 +225,18 @@ describe("describeMcpAuthMethods", () => { ]); }); - it("returns [] for a stdio transport", () => { + it("projects a legacy stdio transport without declared secrets as no-auth", () => { const methods = describeMcpAuthMethods( recordWith({ transport: "stdio", command: "run-server" }), ); - expect(methods).toEqual([]); + expect(methods).toEqual([ + { + id: "none", + label: "No authentication", + kind: "none", + template: "none", + }, + ]); }); it("returns [] for a malformed / foreign / pre-migration config blob", () => { diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 57c4feef2e..6043d81291 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, Predicate, Schema, Tracer } from "effect"; +import { fileURLToPath } from "node:url"; import { HttpClient, HttpClientRequest, @@ -38,6 +39,9 @@ import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; // elicitation.test.ts + owner-isolation.test.ts. const TEMPLATE = AuthTemplateSlug.make("none"); +const stdioNegotiationFixture = fileURLToPath( + new URL("./stdio-negotiation-test-server.ts", import.meta.url), +); const JsonRpcId = Schema.Union([Schema.String, Schema.Number, Schema.Null]); const JsonRpcRequest = Schema.Struct({ @@ -1538,6 +1542,240 @@ describe("mcpPlugin endpoint telemetry", () => { }); describe("stdio static env", () => { + it.effect("uses stored credentials instead of legacy inline stdio env at runtime", () => + Effect.scoped( + Effect.gen(function* () { + const config = makeTestConfig({ + plugins: [ + memoryCredentialsPlugin(), + mcpPlugin({ dangerouslyAllowStdioMCP: true }), + ] as const, + }); + const executor = yield* Effect.acquireRelease(createExecutor(config), (executor) => + executor + .close() + .pipe(Effect.orDie, Effect.ensuring(Effect.promise(() => config.testDb.close()))), + ); + const integration = IntegrationSlug.make("legacy-stdio-with-auth"); + + yield* executor.mcp.addServer({ + name: "Legacy stdio with auth", + endpoint: "http://127.0.0.1:1/mcp", + slug: String(integration), + }); + yield* executor.mcp.configureServer(String(integration), { + transport: "stdio", + command: "bun", + args: ["run", stdioNegotiationFixture], + env: { API_KEY: "legacy-secret" }, + }); + + const projected = yield* executor.integrations.get(integration); + expect(projected?.authMethods).toEqual([ + { + id: "env", + label: "Environment variables", + kind: "apikey", + template: "env", + placements: [{ carrier: "env", name: "API_KEY", prefix: "", variable: "API_KEY" }], + }, + ]); + + const error = yield* executor.connections + .create({ + owner: "org", + name: ConnectionName.make("empty"), + integration, + template: AuthTemplateSlug.make("env"), + values: {}, + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "InvalidConnectionInputError", + message: "A connection must supply at least one credential input.", + }); + expect(yield* executor.connections.list({ integration })).toEqual([]); + + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("fresh"), + integration, + template: AuthTemplateSlug.make("env"), + values: { API_KEY: "fresh-secret" }, + }); + + const result = yield* executor.execute( + ToolAddress.make("tools.legacy-stdio-with-auth.org.fresh.read_env"), + { name: "API_KEY" }, + ); + expect(result).toMatchObject({ + ok: true, + data: { + content: [{ type: "text", text: "fresh-secret" }], + }, + }); + }), + ), + ); + + it.effect("projects legacy stdio without inline env as no-auth and accepts empty values", () => + Effect.scoped( + Effect.gen(function* () { + const config = makeTestConfig({ + plugins: [ + memoryCredentialsPlugin(), + mcpPlugin({ dangerouslyAllowStdioMCP: true }), + ] as const, + }); + const executor = yield* Effect.acquireRelease(createExecutor(config), (executor) => + executor + .close() + .pipe(Effect.orDie, Effect.ensuring(Effect.promise(() => config.testDb.close()))), + ); + const integration = IntegrationSlug.make("legacy-stdio-without-auth"); + + yield* executor.mcp.addServer({ + name: "Legacy stdio without auth", + endpoint: "http://127.0.0.1:1/mcp", + slug: String(integration), + }); + yield* executor.mcp.configureServer(String(integration), { + transport: "stdio", + command: "bun", + args: ["run", stdioNegotiationFixture], + }); + + const projected = yield* executor.integrations.get(integration); + expect(projected?.authMethods).toEqual([ + { + id: "none", + label: "No authentication", + kind: "none", + template: "none", + }, + ]); + + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("public"), + integration, + template: AuthTemplateSlug.make("none"), + values: {}, + }); + expect(String(connection.address)).toBe("tools.legacy-stdio-without-auth.org.public"); + expect(yield* executor.connections.list({ integration })).toHaveLength(1); + }), + ), + ); + + it.effect( + "rejects credential input for legacy no-auth stdio and accepts an empty input map", + () => + Effect.scoped( + Effect.gen(function* () { + const config = makeTestConfig({ + plugins: [ + memoryCredentialsPlugin(), + mcpPlugin({ dangerouslyAllowStdioMCP: true }), + ] as const, + }); + const executor = yield* Effect.acquireRelease(createExecutor(config), (executor) => + executor + .close() + .pipe(Effect.orDie, Effect.ensuring(Effect.promise(() => config.testDb.close()))), + ); + const integration = IntegrationSlug.make("legacy-stdio-no-auth-create"); + + yield* executor.mcp.addServer({ + name: "Legacy stdio no-auth create", + endpoint: "http://127.0.0.1:1/mcp", + slug: String(integration), + }); + yield* executor.mcp.configureServer(String(integration), { + transport: "stdio", + command: "bun", + args: ["run", stdioNegotiationFixture], + }); + + const error = yield* executor.connections + .create({ + owner: "org", + name: ConnectionName.make("with-secret"), + integration, + template: AuthTemplateSlug.make("none"), + value: "must-not-be-stored", + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "InvalidConnectionInputError", + message: "A no-auth connection cannot accept credential inputs.", + }); + expect(yield* executor.connections.list({ integration })).toEqual([]); + + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("public"), + integration, + template: AuthTemplateSlug.make("none"), + values: {}, + }); + expect(String(connection.address)).toBe("tools.legacy-stdio-no-auth-create.org.public"); + expect(yield* executor.connections.list({ integration })).toHaveLength(1); + }), + ), + ); + + it.effect("reconciles a legacy no-secret stdio integration with its default connection", () => + Effect.scoped( + Effect.gen(function* () { + const config = makeTestConfig({ + plugins: [ + memoryCredentialsPlugin(), + mcpPlugin({ dangerouslyAllowStdioMCP: true }), + ] as const, + }); + const executor = yield* Effect.acquireRelease(createExecutor(config), (executor) => + executor + .close() + .pipe(Effect.orDie, Effect.ensuring(Effect.promise(() => config.testDb.close()))), + ); + const slug = "legacy-stdio-no-auth"; + + yield* executor.mcp.addServer({ + name: "Legacy stdio no auth", + endpoint: "http://127.0.0.1:1/mcp", + slug, + }); + yield* executor.mcp.configureServer(slug, { + transport: "stdio", + command: "bun", + args: ["run", stdioNegotiationFixture], + }); + + const projected = yield* executor.integrations.get(IntegrationSlug.make(slug)); + expect(projected?.authMethods).toEqual([ + { + id: "none", + label: "No authentication", + kind: "none", + template: "none", + }, + ]); + + yield* executor.mcp.reconcileStdioConnections(); + + const connections = yield* executor.connections.list({ + integration: IntegrationSlug.make(slug), + }); + expect(connections).toHaveLength(1); + expect(String(connections[0]?.name)).toBe("default"); + + const tools = yield* executor.tools.list({ integration: IntegrationSlug.make(slug) }); + expect(tools.map((tool) => String(tool.name))).toContain("add"); + }), + ), + ); + it("keeps non-secret env off the credential surface", () => { // `env` declares a credential the user must type; `staticEnv` is machine // knowledge stored on the integration. A path the scanner already resolved diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index c075e0ec6b..4af0c4bf58 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -25,6 +25,7 @@ import { type IntegrationConfig, type IntegrationRecord, type OAuthClientSummary, + type OrgWriteDeniedError, type Owner, type PluginCtx, type StaticToolSchema, @@ -417,6 +418,40 @@ const normalizeSlug = (input: McpServerInput): string => /** Slug for a stdio server's secret-env auth method (one per integration). */ const STDIO_ENV_TEMPLATE = "env"; +/** Recover the inline credentials carried by a pre-auth-revamp stdio config. + * A non-null result is the single predicate shared by catalog projection and + * reconciliation: those values must never be mistaken for static env. */ +const legacyStdioInlineCredentials = ( + config: McpStdioIntegrationConfig, +): { + readonly values: Readonly>; + readonly vars: readonly string[]; +} | null => { + const values = config.env ?? {}; + const vars = Object.keys(values); + return vars.length > 0 ? { values, vars } : null; +}; + +/** Project the auth methods a stored MCP config truthfully exposes. Legacy + * stdio rows carried credentials inline and had no declared method, so both + * catalog validation and runtime rendering must see the same synthetic + * method until reconciliation canonicalizes the row. */ +const projectedMcpAuthMethods = (config: McpIntegrationConfigType): readonly McpAuthMethod[] => { + if (config.transport === "stdio" && config.authenticationTemplate === undefined) { + const credentials = legacyStdioInlineCredentials(config); + return credentials === null + ? [{ slug: "none", kind: "none" }] + : [ + { + slug: STDIO_ENV_TEMPLATE, + kind: "stdio_env", + vars: credentials.vars, + }, + ]; + } + return config.authenticationTemplate ?? []; +}; + /** The secret env var NAMES a stdio add declares: the explicit `envVars` * declaration plus the keys of any one-shot `env` values, de-duplicated and * order-preserving. */ @@ -631,7 +666,7 @@ const selectAuthMethod = ( config: McpIntegrationConfigType, templateSlug: string | null, ): McpAuthMethod | undefined => { - const methods = config.authenticationTemplate ?? []; + const methods = projectedMcpAuthMethods(config); if (templateSlug !== null) { const match = methods.find((method: McpAuthMethod) => method.slug === templateSlug); if (match) return match; @@ -846,9 +881,9 @@ export const describeMcpAuthMethods = ( if (!config) return []; // Stdio servers declare a single `stdio_env` method (or `none`); remote - // servers declare header/query/oauth methods. Both project from the same - // optional `authenticationTemplate`. - const methods = config.authenticationTemplate ?? []; + // servers declare header/query/oauth methods. Runtime method selection uses + // this same truthful projection, including synthetic legacy stdio methods. + const methods = projectedMcpAuthMethods(config); return methods.map((method: McpAuthMethod): AuthMethodDescriptor => { if (method.kind === "stdio_env") return describeStdioEnvAuthMethod(method); if (method.kind === "apikey") return describeApiKeyAuthMethod(method); @@ -1220,16 +1255,14 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { }); if (connections.length > 0) return; // already connectable — nothing to heal. - const inlineEnv = config.env ?? {}; - const envVars = Object.keys(inlineEnv); - const hasEnv = envVars.length > 0; + const credentials = legacyStdioInlineCredentials(config); yield* ctx.connections.create({ owner: "org", name: ConnectionName.make("default"), integration: integration.slug, - template: AuthTemplateSlug.make(hasEnv ? STDIO_ENV_TEMPLATE : "none"), - values: hasEnv ? { ...inlineEnv } : {}, + template: AuthTemplateSlug.make(credentials === null ? "none" : STDIO_ENV_TEMPLATE), + values: credentials === null ? {} : { ...credentials.values }, }); // The secret is now on the connection: canonicalize this legacy @@ -1241,9 +1274,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { args: config.args, cwd: config.cwd, versionNegotiation: config.versionNegotiation, - authenticationTemplate: hasEnv - ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] - : [{ slug: "none", kind: "none" }], + authenticationTemplate: + credentials === null + ? [{ slug: "none", kind: "none" }] + : [ + { + slug: STDIO_ENV_TEMPLATE, + kind: "stdio_env", + vars: credentials.vars, + }, + ], }; yield* ctx.core.integrations.update(integration.slug, { config: nextConfig }); }).pipe( @@ -2049,12 +2089,17 @@ export interface McpPluginExtension { input: McpServerInput, ) => Effect.Effect< { readonly slug: string }, - McpExtensionFailure | IntegrationAlreadyExistsError + McpExtensionFailure | IntegrationAlreadyExistsError | OrgWriteDeniedError >; - readonly removeServer: (slug: string) => Effect.Effect; + readonly removeServer: ( + slug: string, + ) => Effect.Effect; /** Ensure every stdio integration has its default connection (migrating any * legacy inline env into the secret store). Idempotent; safe to run at boot. */ - readonly reconcileStdioConnections: () => Effect.Effect; + readonly reconcileStdioConnections: () => Effect.Effect< + void, + McpExtensionFailure | OrgWriteDeniedError + >; readonly getServer: ( slug: string, ) => Effect.Effect< @@ -2064,11 +2109,11 @@ export interface McpPluginExtension { readonly configureServer: ( slug: string, config: McpIntegrationConfigType, - ) => Effect.Effect; + ) => Effect.Effect; readonly configureAuth: ( slug: string, input: McpConfigureAuthInput, - ) => Effect.Effect; + ) => Effect.Effect; /** Locally installed Codex plugins with stdio MCP servers, as one-click * presets. Empty when stdio is disabled. */ readonly listCodexPlugins: () => Effect.Effect; diff --git a/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts b/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts index c31bd2e29a..fcd7987d03 100644 --- a/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts +++ b/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts @@ -18,6 +18,16 @@ serveStdio( { description: "Add two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }) }, async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }), ); + server.registerTool( + "read_env", + { + description: "Read an environment variable", + inputSchema: z.object({ name: z.string() }), + }, + async ({ name }) => ({ + content: [{ type: "text", text: process.env[name] ?? "" }], + }), + ); return server; }, { legacy: process.argv.includes("--legacy-reject") ? "reject" : "serve" }, diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 0ad634dc3a..c4b0fb67ab 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -7,6 +7,7 @@ import { IntegrationAlreadyExistsError, IntegrationNotFoundError, IntegrationSlug, + OrgWriteDeniedError, } from "@executor-js/sdk/shared"; import { @@ -33,6 +34,7 @@ const DomainErrors = [ OpenApiOAuthError, OpenApiSpecOverrideError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ] as const; const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); @@ -44,6 +46,7 @@ const UpdateSpecErrors = [ OpenApiOAuthError, OpenApiSpecOverrideError, IntegrationNotFound, + OrgWriteDeniedError, ] as const; const SlugParams = { diff --git a/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts b/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts index f997788275..48755e5219 100644 --- a/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts +++ b/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts @@ -13,8 +13,8 @@ import { type Authentication } from "./types"; // `describeOpenApiAuthMethods` projects the stored `authenticationTemplate[]` // into the catalog's plugin-agnostic `AuthMethodDescriptor[]` (server-side // mirror of the client's `authMethodsFromConfig`). OpenAPI also renders its own -// accounts slot, so this is consistency work; a malformed/empty config yields -// `[]` with no regression. +// accounts slot, so this is consistency work; an empty valid config describes +// genuine no-auth while malformed/foreign config still yields `[]`. // --------------------------------------------------------------------------- const recordWith = (templates: readonly Authentication[]): IntegrationRecord => ({ @@ -101,8 +101,15 @@ describe("describeOpenApiAuthMethods", () => { expect(methods.map((method) => method.label)).toEqual(["OAuth2 (user)"]); }); - it("returns [] when no auth template is declared and for a foreign config", () => { - expect(describeOpenApiAuthMethods(recordWith([]))).toEqual([]); + it("projects no auth when no template is declared and returns [] for a foreign config", () => { + expect(describeOpenApiAuthMethods(recordWith([]))).toEqual([ + { + id: "none", + label: "No authentication", + kind: "none", + template: "none", + }, + ]); expect( describeOpenApiAuthMethods({ slug: IntegrationSlug.make("x"), @@ -112,7 +119,7 @@ describe("describeOpenApiAuthMethods", () => { canRemove: true, canRefresh: true, authMethods: [], - config: { not: "openapi" } as IntegrationConfig, + config: null, }), ).toEqual([]); }); diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 7f687b86ef..1e125e3b98 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -24,6 +24,7 @@ import { IntegrationAlreadyExistsError, IntegrationSlug, ToolAddress, + makeInMemoryBlobStore, } from "@executor-js/sdk"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -55,6 +56,21 @@ const TOOL_ERROR_TYPESCRIPT = const testPlugins = (httpClientLayer = FetchHttpClient.layer) => [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; +const recordingBlobStore = () => { + const base = makeInMemoryBlobStore(); + let writes = 0; + return { + store: { + ...base, + put: (namespace: string, key: string, value: string) => + Effect.sync(() => { + writes += 1; + }).pipe(Effect.andThen(base.put(namespace, key, value))), + }, + writeCount: () => writes, + }; +}; + // --------------------------------------------------------------------------- // Define a test API with Effect HttpApi // --------------------------------------------------------------------------- @@ -1351,6 +1367,33 @@ paths: ), ); + it.effect("denies member updateSpec before writing an org blob", () => + Effect.gen(function* () { + const blobs = recordingBlobStore(); + const config = makeTestConfig({ plugins: testPlugins() }); + const admin = yield* createExecutor({ ...config, blobs: blobs.store }); + yield* admin.openapi.addSpec({ + spec: { kind: "blob", value: testApiSpecText() }, + slug: "guarded-update", + }); + const writesBeforeDeniedUpdate = blobs.writeCount(); + const member = yield* createExecutor({ + ...config, + blobs: blobs.store, + orgWrites: "denied", + }); + + const error = yield* member.openapi + .updateSpec("guarded-update", { + spec: { kind: "blob", value: testApiSpecText() }, + }) + .pipe(Effect.flip); + + expect(Predicate.isTagged(error, "OrgWriteDeniedError")).toBe(true); + expect(blobs.writeCount()).toBe(writesBeforeDeniedUpdate); + }), + ); + it.effect("updateSpec accepts new inline content for blob-sourced integrations", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index e6d1d9fdad..b41816c1c9 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -18,6 +18,7 @@ import { type IntegrationConfig, type IntegrationPreset, type IntegrationRecord, + type OrgWriteDeniedError, type PluginCtx, type StorageFailure, } from "@executor-js/sdk/core"; @@ -50,7 +51,11 @@ import { } from "./spec-format"; import type { Authentication } from "./types"; import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types"; -import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; +import { + ApiKeyAuthTemplate, + describeApiKeyAuthMethod, + describeNoneAuthMethod, +} from "@executor-js/sdk/http-auth"; import { checkHealthOpenApi, compileAndPersistOpenApiSpecStreaming, @@ -170,6 +175,7 @@ export interface OpenApiPluginExtension { | OpenApiOAuthError | OpenApiSpecOverrideError | IntegrationAlreadyExistsError + | OrgWriteDeniedError | StorageFailure >; /** Re-resolve the integration's spec (from its stored source URL, or the @@ -185,9 +191,10 @@ export interface OpenApiPluginExtension { | OpenApiOAuthError | OpenApiSpecOverrideError | IntegrationNotFoundError + | OrgWriteDeniedError | StorageFailure >; - readonly removeSpec: (slug: string) => Effect.Effect; + readonly removeSpec: (slug: string) => Effect.Effect; readonly getIntegration: (slug: string) => Effect.Effect; /** Read the integration's full opaque config, including its * `authenticationTemplate`. Returns null when the integration is absent. */ @@ -199,7 +206,7 @@ export interface OpenApiPluginExtension { readonly configure: ( slug: string, input: OpenApiConfigureInput, - ) => Effect.Effect; + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -600,26 +607,26 @@ export const describeOpenApiAuthMethods = ( ): readonly AuthMethodDescriptor[] => { const config = decodeOpenApiIntegrationConfig(record.config); if (!config) return []; - return (config.authenticationTemplate ?? []).map( - (template: Authentication): AuthMethodDescriptor => { - if (template.kind === "oauth2") { - return { - id: String(template.slug), - label: template.label ?? "OAuth2", - kind: "oauth", - template: String(template.slug), - oauth: { - authorizationUrl: template.authorizationUrl, - tokenUrl: template.tokenUrl, - resource: template.resource ?? null, - scopes: template.scopes, - supportsClientIdMetadataDocument: template.supportsClientIdMetadataDocument, - }, - }; - } - return describeApiKeyAuthMethod(template); - }, - ); + const templates = config.authenticationTemplate ?? []; + if (templates.length === 0) return [describeNoneAuthMethod("none")]; + return templates.map((template: Authentication): AuthMethodDescriptor => { + if (template.kind === "oauth2") { + return { + id: String(template.slug), + label: template.label ?? "OAuth2", + kind: "oauth", + template: String(template.slug), + oauth: { + authorizationUrl: template.authorizationUrl, + tokenUrl: template.tokenUrl, + resource: template.resource ?? null, + scopes: template.scopes, + supportsClientIdMetadataDocument: template.supportsClientIdMetadataDocument, + }, + }; + } + return describeApiKeyAuthMethod(template); + }); }; export const describeOpenApiIntegrationDisplay = ( @@ -800,6 +807,7 @@ export const openApiPlugin = definePlugin< const addSpec = (config: OpenApiSpecConfig) => Effect.gen(function* () { + yield* ctx.core.integrations.authorizeWrite(); // Resolve URL → text and parse BEFORE opening a transaction. Holding // `BEGIN` across a network fetch is the Hyperdrive deadlock path. const resolved = yield* resolveSpecForInput(config, httpClientLayer); @@ -918,6 +926,7 @@ export const openApiPlugin = definePlugin< // content-addressed (re-puts are idempotent) and an aborted register // leaves only an unreferenced blob behind - while blob backends like // R2 couldn't roll back with the transaction anyway. + yield* ctx.core.integrations.authorizeWrite(); yield* ctx.storage.putSpec(specHash, resolved.specText); if (sourceSpecHash) { yield* ctx.storage.putSpec(sourceSpecHash, resolved.sourceSpecText); @@ -987,6 +996,7 @@ export const openApiPlugin = definePlugin< if (!record || !current) { return yield* new IntegrationNotFoundError({ slug }); } + yield* ctx.core.integrations.authorizeWrite(); // The new spec source: explicit input wins; otherwise re-fetch from // where the spec originally came from. A pasted-blob integration has @@ -1041,6 +1051,7 @@ export const openApiPlugin = definePlugin< const specHash = yield* sha256Hex(resolved.specText); const sourceSpecHash = nextOverrides.length > 0 ? yield* sha256Hex(resolved.sourceSpecText) : undefined; + yield* ctx.core.integrations.authorizeWrite(); yield* ctx.storage.putSpec(specHash, resolved.specText); if (sourceSpecHash) { yield* ctx.storage.putSpec(sourceSpecHash, resolved.sourceSpecText); @@ -1194,7 +1205,7 @@ export const openApiPlugin = definePlugin< configure: ( slug: string, input: OpenApiConfigureInput, - ): Effect.Effect => + ): Effect.Effect => ctx.transaction( Effect.gen(function* () { const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); diff --git a/packages/react/src/components/accounts-section.test.ts b/packages/react/src/components/accounts-section.test.ts new file mode 100644 index 0000000000..e8e93d7231 --- /dev/null +++ b/packages/react/src/components/accounts-section.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { canReconnectConnectionForAccess } from "./accounts-section"; + +describe("connection reconnect access", () => { + it("requires management access for OAuth and non-OAuth connections", () => { + expect(canReconnectConnectionForAccess(false, "oauth")).toBe(false); + expect(canReconnectConnectionForAccess(false, "refresh")).toBe(false); + expect(canReconnectConnectionForAccess(true, "oauth")).toBe(true); + expect(canReconnectConnectionForAccess(true, "refresh")).toBe(true); + }); +}); diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index b79a3c0c13..d9189a796f 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -25,6 +25,7 @@ import { useConnectionHealth } from "../lib/use-connection-health"; import { messageFromExit } from "../api/error-reporting"; import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; import { trackEvent } from "../api/analytics"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import type { AuthMethod } from "../lib/auth-placements"; import { connectionNeedsReconsent, @@ -36,6 +37,7 @@ import { retryReconnectClientsOnMenuOpen, } from "../plugins/oauth-reconnect"; import { useOAuthPopupFlow } from "../plugins/oauth-sign-in"; +import { canManageConnectionForAccess } from "../plugins/connection-owner"; import { AddAccountModal, hasDcr } from "./add-account-modal"; import { ConnectionEditSheet } from "./metadata-edit-sheet"; import type { CreateCustomMethod } from "./add-custom-method-modal"; @@ -124,6 +126,8 @@ function AccountRow(props: { * reconnect to grant the newly-needed access (e.g. after a service was added). */ readonly needsReconsent: boolean; readonly showOwnerLabel: boolean; + readonly canManage: boolean; + readonly canReconnect: boolean; readonly onEdit: () => void; readonly onReconnect: () => void; /** Reconnect routing needs the stored client binding; while the client @@ -289,24 +293,30 @@ function AccountRow(props: { > {checking ? "Checking…" : "Check now"} - - Edit - - - Reconnect - {props.reconnectFailed ? ( - // Same failed-query voice as the modal's picker errors; the - // trailing placement mirrors DropdownMenuShortcut. - Failed to load - ) : null} - - - Remove - + {props.canManage ? ( + + Edit + + ) : null} + {props.canReconnect ? ( + + Reconnect + {props.reconnectFailed ? ( + // Same failed-query voice as the modal's picker errors; the + // trailing placement mirrors DropdownMenuShortcut. + Failed to load + ) : null} + + ) : null} + {props.canManage ? ( + + Remove + + ) : null} @@ -314,10 +324,16 @@ function AccountRow(props: { ); } +export const canReconnectConnectionForAccess = ( + canManageConnections: boolean, + _mode: "oauth" | "refresh", +): boolean => canManageConnections; + function OwnerAccounts(props: { readonly integration: IntegrationSlug; readonly owner: Owner; readonly showOwnerLabels: boolean; + readonly canManageConnections: boolean; readonly methods: readonly AuthMethod[]; readonly onEdit: (connection: Connection) => void; /** Hand the connection to the modal's automatic reconnect flow. Only called @@ -511,6 +527,11 @@ function OwnerAccounts(props: { connection={connection} needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)} showOwnerLabel={props.showOwnerLabels} + canManage={props.canManageConnections} + canReconnect={canReconnectConnectionForAccess( + props.canManageConnections, + reconnectMode(connection), + )} onEdit={() => props.onEdit(connection)} onReconnect={() => void handleReconnect(connection)} // An OAuth Reconnect routes by the stored client binding; without @@ -531,7 +552,7 @@ function OwnerAccounts(props: { ))} { if (!open) setRemovingConnection(null); }} @@ -551,7 +572,9 @@ function OwnerAccounts(props: { { - if (removingConnection !== null) void handleRemove(removingConnection); + if (props.canManageConnections && removingConnection !== null) { + void handleRemove(removingConnection); + } }} > Remove connection @@ -585,13 +608,15 @@ export function AccountsSection(props: { const [editingConnection, setEditingConnection] = useState(null); const [reconnectHandoff, setReconnectHandoff] = useState(null); const ownerDisplay = useOwnerDisplay(); - const canAddConnection = methods.length > 0 || createCustomMethod !== undefined; + const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); + const canAddConnection = + methods.length > 0 || (canCreateWorkspaceConnections && createCustomMethod !== undefined); useEffect(() => { - if (accountHandoff) { + if (accountHandoff && canAddConnection) { setAdding(true); } - }, [accountHandoff]); + }, [accountHandoff, canAddConnection]); // The integration's declared oauth scopes — what connections need granted. A // connection granted fewer is flagged to reconnect (e.g. after a service was @@ -663,14 +688,8 @@ export function AccountsSection(props: {

Connections

- {!showEmptyState ? ( - ) : null} @@ -685,17 +704,15 @@ export function AccountsSection(props: {

No connections yet

- Add a connection to make this integration's tools available. + {canAddConnection + ? "Add a connection to make this integration's tools available." + : "Ask a workspace admin to configure an authentication method for this integration."}

- + {canAddConnection ? ( + + ) : null}
) : (
@@ -705,6 +722,10 @@ export function AccountsSection(props: { integration={integration} owner={owner} showOwnerLabels={ownerDisplay.showOwnerLabels} + canManageConnections={canManageConnectionForAccess( + owner, + canCreateWorkspaceConnections, + )} methods={methods} onEdit={setEditingConnection} onDcrReconnect={( diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 3acd4007c2..5e10fd509f 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -52,10 +52,11 @@ import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; import { messageFromExit } from "../api/error-reporting"; import { trackEvent } from "../api/analytics"; import { useOrganizationId } from "../api/organization-context"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { ownerLabel, ownerLabelForHost, useOwnerDisplay } from "../api/owner-display"; import { ConnectionOwnerDropdown, - connectionOwnerOptionsForHost, + connectionOwnerOptionsForAccess, defaultConnectionOwnerForHost, normalizeConnectionOwner, resolveOAuthConnectionOwnerForHost, @@ -1406,16 +1407,25 @@ function AddAccountModalView(props: AddAccountModalProps) { open, onOpenChange, initialState, - createCustomMethod, - removeCustomMethod, + createCustomMethod: requestedCreateCustomMethod, + removeCustomMethod: requestedRemoveCustomMethod, } = props; const organizationId = useOrganizationId(); const ownerDisplay = useOwnerDisplay(); - const ownerOptions = useMemo( - () => connectionOwnerOptionsForHost(organizationId), - [organizationId], - ); + const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); + const ownerOptions = useMemo(() => { + return connectionOwnerOptionsForAccess(organizationId, canCreateWorkspaceConnections); + }, [canCreateWorkspaceConnections, organizationId]); const defaultOwner = defaultConnectionOwnerForHost(organizationId); + // Custom methods mutate the workspace-wide integration catalog, so they stay + // admin-only even though members can add Personal connections using methods + // that an admin has already configured. + const createCustomMethod = canCreateWorkspaceConnections + ? requestedCreateCustomMethod + : undefined; + const removeCustomMethod = canCreateWorkspaceConnections + ? requestedRemoveCustomMethod + : undefined; // The selectable methods: the declared ones plus any custom method created in // this session (so a just-created method shows + can be selected before the @@ -1879,6 +1889,9 @@ function AddAccountModalView(props: AddAccountModalProps) { ): { readonly onEdit: () => void; readonly onRemove: () => void } | undefined => { // First-party apps are host config, not rows: nothing to edit or remove. if (appOption.origin.kind === "first_party") return undefined; + // Members may use a shared app to mint their own Personal connection, but + // only admins may edit or remove that Workspace-owned app. + if (appOption.owner === "org" && !canCreateWorkspaceConnections) return undefined; const summary = clientSummaries.find( (c: OAuthClientSummary) => c.owner === appOption.owner && String(c.slug) === String(appOption.slug), @@ -2815,7 +2828,9 @@ function AddAccountModalView(props: AddAccountModalProps) { {ownerDisplay.showOwnerLabels - ? "A connection is a saved way to use this integration, owned by you or the workspace." + ? canCreateWorkspaceConnections + ? "A connection is a saved way to use this integration, owned by you or the workspace." + : "A connection is a saved way to use this integration, owned by you." : "A connection is a saved way to use this integration."} diff --git a/packages/react/src/components/oauth-client-form.test.ts b/packages/react/src/components/oauth-client-form.test.ts index ddd3dd5377..c0ea6f1ba0 100644 --- a/packages/react/src/components/oauth-client-form.test.ts +++ b/packages/react/src/components/oauth-client-form.test.ts @@ -4,10 +4,15 @@ import { Schema } from "effect"; import { canSubmitOAuthClientForm, + initialOAuthClientOwner, preferredManualTokenEndpointAuthMethod, registrationScopes, resolveOriginIntegration, } from "./oauth-client-form"; +import { + connectionOwnerOptionsForAccess, + normalizeConnectionOwner, +} from "../plugins/connection-owner"; import { oauthAppSetupFor } from "./oauth-app-setup"; const validBase = { @@ -137,6 +142,18 @@ describe("canSubmitOAuthClientForm", () => { }); }); +describe("OAuth client owner default", () => { + it("restores Workspace when loading resolves to admin unless Personal was chosen", () => { + const defaultChoice = initialOAuthClientOwner(undefined); + const loadingOptions = connectionOwnerOptionsForAccess("org_123", false); + expect(normalizeConnectionOwner(defaultChoice, loadingOptions)).toBe("user"); + + const adminOptions = connectionOwnerOptionsForAccess("org_123", true); + expect(normalizeConnectionOwner(defaultChoice, adminOptions)).toBe("org"); + expect(normalizeConnectionOwner("user", adminOptions)).toBe("user"); + }); +}); + describe("preferredManualTokenEndpointAuthMethod", () => { it("selects Basic when it is the only advertised confidential method", () => { expect(preferredManualTokenEndpointAuthMethod(["client_secret_basic"])).toBe("basic"); diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index 74ee1f654a..d6e50b6157 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -16,12 +16,13 @@ import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } f import { ownerLabelForHost } from "../api/owner-display"; import { trackEvent } from "../api/analytics"; import { useOrganizationId } from "../api/organization-context"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { oauthClientWriteKeys } from "../api/reactivity-keys"; import { optimisticDcrClientSlug, uniqueClientSlug } from "../plugins/use-effective-oauth-client"; import { oauthCallbackUrl } from "../plugins/oauth-sign-in"; import { ConnectionOwnerDropdown, - connectionOwnerOptionsForHost, + connectionOwnerOptionsForAccess, normalizeConnectionOwner, } from "../plugins/connection-owner"; import { Button } from "./button"; @@ -122,6 +123,13 @@ export const canSubmitOAuthClientForm = (input: { input.tokenUrl.trim().length > 0 && (input.grant === "client_credentials" || input.authorizationUrl.trim().length > 0); +/** Preserve the Workspace default as the user's preference while role data is + * loading. The effective owner is clamped separately on every render, so a + * member still submits Personal; when an admin role resolves, Workspace is + * restored unless the user actively selected Personal. */ +export const initialOAuthClientOwner = (fixedOwner: Owner | undefined): Owner => + fixedOwner ?? "org"; + export function OAuthClientForm(props: { /** Human label for the integration this app backs (used in toasts + default name). */ readonly integrationName: string; @@ -175,9 +183,10 @@ export function OAuthClientForm(props: { // Non-org hosts (local/desktop) have one local workspace. Offer only Local, // so the owner dropdown (which hides on a single option) disappears. const organizationId = useOrganizationId(); + const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); const ownerOptions = useMemo( - () => connectionOwnerOptionsForHost(organizationId), - [organizationId], + () => connectionOwnerOptionsForAccess(organizationId, canCreateWorkspaceConnections), + [canCreateWorkspaceConnections, organizationId], ); // The browser-facing callback the OAuth flow uses (this host's @@ -187,12 +196,15 @@ export function OAuthClientForm(props: { // it is automatically correct per platform (cloud / self-host / local). const callbackUrl = useMemo(() => oauthCallbackUrl(), []); - // Explicit create-time choice (no ambient owner). Default Workspace (`org`) on - // an org host, Local (`org`) on a non-org host, or the locked owner when - // editing. - const [owner, setOwner] = useState( - normalizeConnectionOwner(fixedOwner ?? "org", ownerOptions), + // Explicit create-time choice (no ambient owner). Admins default to Workspace + // (`org`) on an org host; members are clamped to Personal (`user`); non-org + // hosts use Local (`org`). Editing may lock the existing owner. + const [selectedOwner, setSelectedOwner] = useState(() => + initialOAuthClientOwner(fixedOwner), ); + // Role loading and workspace switches can invalidate a prior selection. + // Derive the effective owner every render so no submit observes stale state. + const owner = normalizeConnectionOwner(selectedOwner, ownerOptions); const [name, setName] = useState(integrationName); const [grant, setGrant] = useState(prefill?.grant ?? "authorization_code"); const [clientId, setClientId] = useState(prefill?.clientId ?? ""); @@ -755,7 +767,7 @@ export function OAuthClientForm(props: {

- {ownerLabelForHost(fixedOwner, organizationId)} + {ownerLabelForHost(owner, organizationId)} can't change after creation @@ -765,7 +777,7 @@ export function OAuthClientForm(props: { setOwner(next)} + onChange={(next: Owner) => setSelectedOwner(next)} label="Register app for" help={`Personal apps are yours only; Workspace apps are shared with everyone (each ${ownerLabelForHost( "user", diff --git a/packages/react/src/lib/admin-access.test.ts b/packages/react/src/lib/admin-access.test.ts index 47a3cc7df2..e2015f693f 100644 --- a/packages/react/src/lib/admin-access.test.ts +++ b/packages/react/src/lib/admin-access.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; -import { isTenantAdminMember, type TenantMemberRow } from "./admin-access"; +import { + canCreateWorkspaceConnectionsForHost, + isTenantAdminMember, + type TenantMemberRow, +} from "./admin-access"; +import { + connectionOwnerOptionsForAccess, + normalizeConnectionOwner, +} from "../plugins/connection-owner"; const member = (overrides: Partial = {}): TenantMemberRow => ({ role: "member", @@ -50,3 +58,23 @@ describe("isTenantAdminMember", () => { expect(isTenantAdminMember([member({ role: "billing", isCurrentUser: true })])).toBe(false); }); }); + +describe("canCreateWorkspaceConnectionsForHost", () => { + it("allows Workspace connection creation on single-user hosts", () => { + expect(canCreateWorkspaceConnectionsForHost(null, false)).toBe(true); + }); + + it("allows organization admins and refuses organization members", () => { + expect(canCreateWorkspaceConnectionsForHost("org_123", true)).toBe(true); + expect(canCreateWorkspaceConnectionsForHost("org_123", false)).toBe(false); + }); + + it("clamps a stale Workspace form owner when an admin becomes a member", () => { + const adminOptions = connectionOwnerOptionsForAccess("org_123", true); + const selectedOwner = normalizeConnectionOwner("org", adminOptions); + expect(selectedOwner).toBe("org"); + + const memberOptions = connectionOwnerOptionsForAccess("org_123", false); + expect(normalizeConnectionOwner(selectedOwner, memberOptions)).toBe("user"); + }); +}); diff --git a/packages/react/src/lib/admin-access.ts b/packages/react/src/lib/admin-access.ts index 433b4aa681..5bbf598106 100644 --- a/packages/react/src/lib/admin-access.ts +++ b/packages/react/src/lib/admin-access.ts @@ -44,3 +44,11 @@ export const isTenantAdminMember = (members: readonly TenantMemberRow[]): boolea (member) => member.isCurrentUser && member.status === "active" && TENANT_ADMIN_ROLES.has(member.role), ); + +/** Workspace connection creation is unrestricted on single-user hosts. + * Organization hosts require the active member to be an admin or owner; + * Personal connection creation remains available to every active member. */ +export const canCreateWorkspaceConnectionsForHost = ( + organizationId: string | null, + isTenantAdmin: boolean, +): boolean => organizationId === null || isTenantAdmin; diff --git a/packages/react/src/multiplayer/use-admin-nav.tsx b/packages/react/src/multiplayer/use-admin-nav.tsx index c29dee29e6..05c33436e8 100644 --- a/packages/react/src/multiplayer/use-admin-nav.tsx +++ b/packages/react/src/multiplayer/use-admin-nav.tsx @@ -2,7 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { orgMembersAtom } from "../api/account-atoms"; -import { isTenantAdminMember, type TenantMemberRow } from "../lib/admin-access"; +import { useOrganizationId } from "../api/organization-context"; +import { + canCreateWorkspaceConnectionsForHost, + isTenantAdminMember, + type TenantMemberRow, +} from "../lib/admin-access"; import type { ShellNavItem } from "./shell"; // --------------------------------------------------------------------------- @@ -38,6 +43,14 @@ export const useIsTenantAdmin = (): boolean => { }); }; +/** Whether this host and active role allow adding Workspace credentials. + * Personal connection creation is available to every active member. */ +export const useCanCreateWorkspaceConnections = (): boolean => { + const organizationId = useOrganizationId(); + const isAdmin = useIsTenantAdmin(); + return canCreateWorkspaceConnectionsForHost(organizationId, isAdmin); +}; + /** * Append admin-only nav items to a host's nav, for admins only. * diff --git a/packages/react/src/pages/integration-detail.tsx b/packages/react/src/pages/integration-detail.tsx index 0f2c0d1bd2..522fea34d9 100644 --- a/packages/react/src/pages/integration-detail.tsx +++ b/packages/react/src/pages/integration-detail.tsx @@ -681,15 +681,11 @@ function NoConnectionToolsEmptyState(props: {

Add a connection to unlock this integration's tools.

- + {props.canAddConnection ? ( + + ) : null}
); diff --git a/packages/react/src/plugins/connection-owner.test.ts b/packages/react/src/plugins/connection-owner.test.ts index 0d1e8f9602..1398b97500 100644 --- a/packages/react/src/plugins/connection-owner.test.ts +++ b/packages/react/src/plugins/connection-owner.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import { + canManageConnectionForAccess, connectionOwnerOptions, + connectionOwnerOptionsForAccess, connectionOwnerOptionsForHost, defaultConnectionOwnerForHost, normalizeConnectionOwner, @@ -34,6 +36,40 @@ describe("connectionOwnerOptions", () => { it("keeps Personal as the default owner for org-scoped hosts", () => { expect(defaultConnectionOwnerForHost("org_123")).toBe("user"); }); + + it("gives members exactly one forced Personal option", () => { + expect(connectionOwnerOptionsForAccess("org_123", false)).toEqual([ + { + owner: "user", + label: "Personal", + description: "Saved only for your account.", + }, + ]); + }); + + it("keeps both choices for admins and Local for single-user hosts", () => { + expect(connectionOwnerOptionsForAccess("org_123", true).map((option) => option.owner)).toEqual([ + "user", + "org", + ]); + expect(connectionOwnerOptionsForAccess(null, false).map((option) => option.owner)).toEqual([ + "org", + ]); + }); +}); + +describe("canManageConnectionForAccess", () => { + it("keeps Personal Edit and Remove available to members", () => { + expect(canManageConnectionForAccess("user", false)).toBe(true); + }); + + it("hides Workspace Edit and Remove from members", () => { + expect(canManageConnectionForAccess("org", false)).toBe(false); + }); + + it("keeps Workspace Edit and Remove available to admins", () => { + expect(canManageConnectionForAccess("org", true)).toBe(true); + }); }); describe("normalizeConnectionOwner", () => { diff --git a/packages/react/src/plugins/connection-owner.tsx b/packages/react/src/plugins/connection-owner.tsx index 88853463e0..3e8f8ab79b 100644 --- a/packages/react/src/plugins/connection-owner.tsx +++ b/packages/react/src/plugins/connection-owner.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { Owner } from "@executor-js/sdk/shared"; import { useOrganizationId } from "../api/organization-context"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { CardStack, CardStackContent, @@ -67,6 +68,26 @@ export const connectionOwnerOptionsForHost = ( ): readonly ConnectionOwnerOption[] => organizationId === null ? localConnectionOwnerOptions() : connectionOwnerOptions(); +/** Owner choices visible to the active role. Members of organization hosts get + * exactly one Personal option, which both forces `owner: "user"` and makes the + * owner dropdown disappear. Admins retain Personal + Workspace. Local hosts + * retain their single Local option regardless of tenant-role loading. */ +export const connectionOwnerOptionsForAccess = ( + organizationId: string | null, + canCreateWorkspaceConnections: boolean, +): readonly ConnectionOwnerOption[] => { + const options = connectionOwnerOptionsForHost(organizationId); + return organizationId === null || canCreateWorkspaceConnections + ? options + : options.filter((option) => option.owner === "user"); +}; + +/** Whether the active role may mutate a connection with this owner. */ +export const canManageConnectionForAccess = ( + owner: Owner, + canCreateWorkspaceConnections: boolean, +): boolean => owner === "user" || canCreateWorkspaceConnections; + export const defaultConnectionOwnerForHost = (organizationId: string | null): Owner => organizationId === null ? LOCAL_CONNECTION_OWNER : DEFAULT_CONNECTION_OWNER; @@ -107,7 +128,8 @@ export function useConnectionOwner(input?: { readonly initialOwner?: Owner }): { readonly connectionOwnerOptions: readonly ConnectionOwnerOption[]; } { const organizationId = useOrganizationId(); - const options = connectionOwnerOptionsForHost(organizationId); + const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); + const options = connectionOwnerOptionsForAccess(organizationId, canCreateWorkspaceConnections); const [connectionOwner, setConnectionOwner] = useState( input?.initialOwner ?? defaultConnectionOwnerForHost(organizationId), ); diff --git a/packages/react/src/plugins/secret-form.test.ts b/packages/react/src/plugins/secret-form.test.ts new file mode 100644 index 0000000000..b03bb1fecc --- /dev/null +++ b/packages/react/src/plugins/secret-form.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; + +import { InternalError } from "@executor-js/sdk/shared"; + +import { credentialSaveErrorMessage } from "./secret-form"; + +describe("credentialSaveErrorMessage", () => { + it("shows retry guidance and the correlation id for retryable saves", () => { + const exit = Exit.fail(new InternalError({ traceId: "trace-123", retryable: true })); + + expect(credentialSaveErrorMessage(exit)).toBe( + "The credential save didn't complete. Try again. Reference ID: trace-123", + ); + }); + + it("keeps unexpected failures opaque", () => { + const exit = Exit.fail(new InternalError({ traceId: "trace-500" })); + + expect(credentialSaveErrorMessage(exit)).toBe("Failed to save credential"); + }); +}); diff --git a/packages/react/src/plugins/secret-form.tsx b/packages/react/src/plugins/secret-form.tsx index a3b9a9c8bb..659d0779df 100644 --- a/packages/react/src/plugins/secret-form.tsx +++ b/packages/react/src/plugins/secret-form.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react"; import { useAtomSet } from "@effect/atom-react"; +import { Option, Schema } from "effect"; import * as Exit from "effect/Exit"; import { createConnection } from "../api/atoms"; @@ -15,6 +16,7 @@ import { connectionWriteKeys } from "../api/reactivity-keys"; import { AuthTemplateSlug, ConnectionName, + InternalError, IntegrationSlug, type Owner, } from "@executor-js/sdk/shared"; @@ -72,6 +74,20 @@ interface SecretFormContextValue { } const SecretFormContext = createContext(null); +const isInternalError = Schema.is(InternalError); + +/** Project a connection-create failure into safe, actionable form copy. */ +export const credentialSaveErrorMessage = (exit: Exit.Exit): string => + Option.match(Exit.findErrorOption(exit), { + onNone: () => "Failed to save credential", + onSome: (error) => { + if (!isInternalError(error) || error.retryable !== true) return "Failed to save credential"; + const traceId = error.traceId.trim(); + return traceId.length > 0 + ? `The credential save didn't complete. Try again. Reference ID: ${traceId}` + : "The credential save didn't complete. Try again."; + }, + }); function useSecretForm(): SecretFormContextValue { const ctx = use(SecretFormContext); @@ -165,7 +181,7 @@ function SecretFormProvider(props: SecretFormProviderProps) { ...s, status: { kind: "error", - message: "Failed to save credential", + message: credentialSaveErrorMessage(exit), }, })); return;