diff --git a/.changeset/workspace-writes-admin-only.md b/.changeset/workspace-writes-admin-only.md new file mode 100644 index 000000000..7044e77a8 --- /dev/null +++ b/.changeset/workspace-writes-admin-only.md @@ -0,0 +1,30 @@ +--- +"@executor-js/sdk": minor +"@executor-js/api": minor +"@executor-js/plugin-graphql": minor +"@executor-js/plugin-mcp": minor +"@executor-js/plugin-openapi": minor +--- + +**Workspace-level settings are now admin-only** + +The executor binding gains `orgWrites: "allowed" | "denied"`. Hosts derive it +from the acting member's role (cloud: WorkOS membership role; self-host: +Better Auth org membership role), and a plain member's binding refuses every +user-intent workspace-level mutation with the new `OrgWriteDeniedError` +(HTTP 403): Workspace connections, org-owned tool policies, org OAuth apps and +org connect flows, and integration-catalog changes (add, update, remove, health +check). Plain members can still add and manage Personal connections; the +console removes the Workspace choice while retaining the Personal flow. + +Using workspace resources is unchanged for members: reads, tool execution over +shared connections, and the operational writes those imply (token refresh, +tool-catalog re-sync, config-rewrite healing) keep working. Hosts with no role +model (local, the CLI, embedded SDK use) default to `"allowed"`. + +Successful connection, integration, and OAuth-client create/update/remove +operations now write a tenant-scoped audit event with the acting user, resource +scope, and safe identifiers. Admins can list the newest events through +the Users page's Activity tab or `GET /admin/audit-events`; actor email and +display name are joined from the host directory, while credentials and +free-form configuration are never stored in or returned by the audit surface. diff --git a/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql b/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql new file mode 100644 index 000000000..bb5b67360 --- /dev/null +++ b/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql @@ -0,0 +1,14 @@ +CREATE TABLE "audit_event" ( + "id" text NOT NULL, + "actor_id" text, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_owner" text, + "resource_parent" text, + "resource_id" text NOT NULL, + "created_at" timestamp NOT NULL, + "row_id" text PRIMARY KEY NOT NULL, + "tenant" text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "audit_event_uidx" ON "audit_event" USING btree ("tenant","created_at","id"); diff --git a/apps/cloud/drizzle/meta/0016_snapshot.json b/apps/cloud/drizzle/meta/0016_snapshot.json new file mode 100644 index 000000000..45b9d0f23 --- /dev/null +++ b/apps/cloud/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1587 @@ +{ + "id": "c84f378d-37c9-496e-aa01-3208aa59baa6", + "prevId": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55", + "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.audit_event": { + "name": "audit_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_owner": { + "name": "resource_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_parent": { + "name": "resource_parent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_event_uidx": { + "name": "audit_event_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "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 + }, + "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 + }, + "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 fa9057083..eb2b007bb 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1785355354955, "tag": "0015_equal_the_leader", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1787860949290, + "tag": "0016_fantastic_colleen_wing", + "breakpoints": true } ] } diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts index 351f8d46e..bb306d000 100644 --- a/apps/cloud/src/admin/admin-users-api.ts +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -35,6 +35,7 @@ import { HostConfig, PluginsProvider, getAdminUser, + listAdminAuditEvents, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, @@ -286,6 +287,14 @@ export const workosAdminUsersProvider: Layer.Layer< WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig >(); return AdminUsersProvider.of({ + listAuditEvents: (headers, options) => + withPlatformView(headers, (executor, organizationId) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + listAdminAuditEvents(admin, options, userDirectory(organizationId, context)), + ), + ), + ).pipe(Effect.provideContext(context)), listUsers: (headers, options) => withPlatformView(headers, (executor, organizationId) => platformViewOf(executor).pipe( 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 3ba01a61b..edf8cdc42 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,9 @@ 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. + 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 dbf35e1c2..afd67cd75 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,9 @@ 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. + orgRole: "member", }); }), ); diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts index 073dfacb3..5aceb2ab1 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 95742038f..40d947f5c 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -154,6 +154,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => name: null, avatarUrl: null, roles: [], + orgRole: org.memberRole, } satisfies Principal; }); @@ -253,6 +254,7 @@ export const resolveBearerAuth = ( name: null, avatarUrl: null, roles: [], + orgRole: org.memberRole, } satisfies Principal; }); @@ -326,6 +328,7 @@ export const resolveSessionPrincipal = (request: Request) => name: sealedSessionDisplayName(session), avatarUrl: session.avatarUrl ?? null, roles: [], + orgRole: org.memberRole, } satisfies Principal; }); diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index e23d30d07..fa141e9ce 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -49,6 +49,26 @@ export const subject = pgTable( (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)], ); +export const audit_event = pgTable( + "audit_event", + { + id: text("id").notNull(), + actor_id: text("actor_id"), + action: text("action").notNull(), + resource_type: text("resource_type").notNull(), + resource_owner: text("resource_owner"), + resource_parent: text("resource_parent"), + resource_id: text("resource_id").notNull(), + created_at: timestamp("created_at").notNull(), + row_id: text("row_id") + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: text("tenant").notNull(), + }, + (table) => [uniqueIndex("audit_event_uidx").on(table.tenant, table.created_at, table.id)], +); + export const connection = pgTable( "connection", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index 86faa45bb..ada5d4ed2 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -29,6 +29,7 @@ import * as executorSchema from "./executor-schema"; import { memberships, accounts } from "./schema"; import { artifact, + audit_event, blob, connection, definition, @@ -147,6 +148,18 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { tenant, }); + await db.insert(audit_event).values({ + id: `aud-${tag}`, + actor_id: `acct-${tag}`, + action: "created", + resource_type: "connection", + resource_owner: "org", + resource_parent: "int", + resource_id: `conn-${tag}`, + created_at: now, + tenant, + }); + await db.insert(artifact).values({ id: `art-${tag}`, title: "Dashboard", @@ -184,6 +197,7 @@ const TENANT_TABLES = [ tool_policy, plugin_storage, subject, + audit_event, artifact, ] as const; diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index b2a922a3f..97a7585ae 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -17,6 +17,7 @@ import type { DrizzleDb } from "./db"; import { organizations } from "./schema"; import { artifact, + audit_event, blob, connection, definition, @@ -50,6 +51,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); + await tx.delete(audit_event).where(eq(audit_event.tenant, organizationId)); await tx.delete(subject).where(eq(subject.tenant, organizationId)); await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 0ec697c91..589853e6e 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -181,6 +181,7 @@ const propsForPrincipal = ( // "carried, and blank". ...(principal.organizationName ? { organizationName: principal.organizationName } : {}), ...(principal.organizationSlug ? { organizationSlug: principal.organizationSlug } : {}), + ...(principal.orgRole ? { orgRole: principal.orgRole } : {}), userId: principal.accountId, elicitationMode: readElicitationMode(request), artifactsEnabled: readArtifactsEnabled(request), diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 5ff5ee2f0..cf67cd179 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -272,7 +272,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("preserves a stored role when legacy init props do not carry one", 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).toBe("admin"); + expect(store.calls()).toBe(0); + }); + it("reads the database only when nothing else names the org", async () => { const store = namingStore(); @@ -117,6 +134,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 a0bd1df81..81891a0a7 100644 --- a/apps/cloud/src/mcp/session-meta.ts +++ b/apps/cloud/src/mcp/session-meta.ts @@ -111,17 +111,25 @@ 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; + readonly orgRole?: "admin" | "member"; + }, +): SessionMeta => { + const orgRole = token.orgRole ?? organization.orgRole; + return { + organizationId: token.organizationId, + organizationName: organization.name, + ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), + ...(orgRole === undefined ? {} : { orgRole }), + 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 @@ -200,6 +208,7 @@ export const resolveSessionMetaForToken = ( return metaFromIdentity(token, { name: storedMeta.organizationName, slug: storedMeta.organizationSlug, + orgRole: storedMeta.orgRole, }); } diff --git a/apps/host-selfhost/src/admin/admin-escalation.node.test.ts b/apps/host-selfhost/src/admin/admin-escalation.node.test.ts index 3e44d36dd..f7ed546b1 100644 --- a/apps/host-selfhost/src/admin/admin-escalation.node.test.ts +++ b/apps/host-selfhost/src/admin/admin-escalation.node.test.ts @@ -126,6 +126,7 @@ test("a member cannot escalate by owning an organization of their own", async () // Every admin users route, not just the list: a gate applied at four call // sites can be fixed at three. for (const path of [ + "/api/admin/audit-events", "/api/admin/users", "/api/admin/users/with-connections", "/api/admin/users/user_anyone/connections", diff --git a/apps/host-selfhost/src/admin/admin-users-api.ts b/apps/host-selfhost/src/admin/admin-users-api.ts index 38f767a16..4f11b8fbb 100644 --- a/apps/host-selfhost/src/admin/admin-users-api.ts +++ b/apps/host-selfhost/src/admin/admin-users-api.ts @@ -28,6 +28,7 @@ import { HostConfig, PluginsProvider, getAdminUser, + listAdminAuditEvents, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, @@ -159,6 +160,14 @@ export const betterAuthAdminUsersProvider: Layer.Layer< const context = yield* Effect.context(); const { auth, organizationId } = yield* BetterAuth; return AdminUsersProvider.of({ + listAuditEvents: (headers, options) => + withPlatformView(headers, organizationId, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + listAdminAuditEvents(admin, options, userDirectory(auth, headers)), + ), + ), + ).pipe(Effect.provideContext(context)), listUsers: (headers, options) => withPlatformView(headers, organizationId, (executor) => platformViewOf(executor).pipe( diff --git a/apps/host-selfhost/src/admin/admin-users.node.test.ts b/apps/host-selfhost/src/admin/admin-users.node.test.ts index 149c0655e..781ea2dba 100644 --- a/apps/host-selfhost/src/admin/admin-users.node.test.ts +++ b/apps/host-selfhost/src/admin/admin-users.node.test.ts @@ -135,6 +135,13 @@ test("the owner sees who uses the instance; a plain member cannot look", async ( "the joined view reports the same identities, keyed the same way", ).toEqual(body.users.map((user) => [user.externalId, user.email]).sort()); + const audit = await adminUsers(adminToken, "/api/admin/audit-events"); + expect(audit.status).toBe(200); + expect( + Array.isArray(((await audit.json()) as { events: readonly unknown[] }).events), + "the owner receives the audit collection", + ).toBe(true); + // ------------------------------------------------------------------------- // The single-user read, by opaque id and by email. // ------------------------------------------------------------------------- @@ -221,10 +228,12 @@ test("the owner sees who uses the instance; a plain member cannot look", async ( // The gate: a plain member may not read the instance-wide view. const asMember = await adminUsers(memberToken); expect(asMember.status, "a member is refused").toBe(403); + expect((await adminUsers(memberToken, "/api/admin/audit-events")).status).toBe(403); // And an anonymous caller has no session at all. const anonymous = await adminUsers(); expect(anonymous.status, "no session → unauthorized").toBe(401); + expect((await adminUsers(undefined, "/api/admin/audit-events")).status).toBe(401); // The single-user read refuses on the same terms — and refuses BEFORE // looking, so a refused caller cannot probe which users exist. diff --git a/apps/host-selfhost/src/admin/require-admin.ts b/apps/host-selfhost/src/admin/require-admin.ts index 4b0fd8b7d..794f2170e 100644 --- a/apps/host-selfhost/src/admin/require-admin.ts +++ b/apps/host-selfhost/src/admin/require-admin.ts @@ -74,8 +74,12 @@ export interface InstanceAdmin { * string is the common case rather than the contract. Membership in the * privileged set is therefore tested per role, not by equality on the whole * field — an `"owner,admin"` value must not read as neither. + * + * Exported for the identity seam: the same "who counts as an admin" answer + * decides the executor's workspace-write binding (`Principal.orgRole`), and + * there is only one place to be right about it. */ -const isPrivileged = (role: string): boolean => +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 932e90230..0a1abcb7d 100644 --- a/apps/host-selfhost/src/auth/identity.ts +++ b/apps/host-selfhost/src/auth/identity.ts @@ -2,6 +2,7 @@ import { Effect, Layer } from "effect"; import { IdentityProvider, Unauthorized } from "@executor-js/api/server"; +import { isPrivileged } from "../admin/require-admin"; import { BetterAuth } from "./better-auth"; // --------------------------------------------------------------------------- @@ -49,13 +50,18 @@ export const betterAuthIdentityLayer: Layer.Layer 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 +72,21 @@ export const betterAuthIdentityLayer: Layer.Layer + auth.api.getActiveMemberRole({ + headers: sessionHeaders, + query: { organizationId: resolvedOrganizationId }, + }), + ).pipe(Effect.orElseSucceed(() => null)); + const orgRole = + membership && isPrivileged(membership.role) + ? ("admin" as const) + : ("member" as const); return { kind: "member" as const, accountId: resolved.user.id, @@ -79,6 +100,7 @@ export const betterAuthIdentityLayer: Layer.Layer role.trim()) .filter((role) => role.length > 0), + orgRole, }; }), }); diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index abd07ba38..ddfd24c49 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,7 @@ export const selfHostMcpAuth: Layer.Layer => { - 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/e2e/cloud/admin-users-console.test.ts b/e2e/cloud/admin-users-console.test.ts index d5eaa0521..72cb25e24 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" } } }, @@ -91,8 +92,7 @@ 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 adminConnection = freshConnectionName(); @@ -100,9 +100,8 @@ scenario( 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 +111,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 +200,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 +210,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 +220,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,15 +228,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", - ).toBe(1); // The other member's credential is not this member's business. expect( await detail.getByText(adminConnection, { exact: true }).count(), @@ -285,11 +290,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(), @@ -362,6 +368,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 70bf366b9..2be5a2930 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 b268aec78..e6b6d353c 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,90 +135,49 @@ 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"); - }); - - await step("Complete the connection from that page", async () => { + expect( + new URL(page.url()).searchParams.get("addAccount"), + "the member enters the add-account route", + ).toBe("1"); 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 }); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); expect( - new URL(page.url()).pathname.split("/").filter(Boolean)[0], - "the credential was saved from a page scoped to the SENDING org", - ).toBe(orgA.slug); + await dialog.getByText("Workspace", { exact: true }).count(), + "the member is forced to Personal scope", + ).toBe(0); }); // ── The other half: it is NOT in the session's default org ───────── // // 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 has its own Personal add flow too. + await step("Org B has a separate Personal add action", 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 }); - await page - .getByText("No connections", { exact: false }) - .first() - .waitFor({ state: "visible", timeout: 90_000 }); + const add = page.getByRole("button", { name: "Add connection" }); + await add.waitFor({ state: "visible", timeout: 90_000 }); expect( - await page.getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }).count(), - "nothing landed in the org the recipient's session happened to default to", - ).toBe(0); + new URL(page.url()).pathname.split("/").filter(Boolean)[0], + "navigating explicitly to org B changes the active add-flow scope", + ).toBe(orgB.slug); }); }); }), - // 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/selfhost/admin-users-console.test.ts b/e2e/selfhost/admin-users-console.test.ts index fccdef925..ddada0bed 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" } } }, @@ -94,8 +95,20 @@ scenario( 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 +132,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 +325,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/packages/core/api/src/admin/admin-users.test.ts b/packages/core/api/src/admin/admin-users.test.ts index 684c81ff6..0a1987612 100644 --- a/packages/core/api/src/admin/admin-users.test.ts +++ b/packages/core/api/src/admin/admin-users.test.ts @@ -20,6 +20,7 @@ import { AdminUsersHandlers } from "./handlers"; import { AdminUsersProvider, type AdminUsersHeaders } from "./service"; import { getUser, + listAuditEvents, listUserConnections, listUsers, listUsersWithConnections, @@ -158,6 +159,40 @@ const insertConnection = ( }); }); +const insertAuditEvent = ( + db: SqliteTestFumaDb, + row: { + readonly id: string; + readonly tenant: string; + readonly actorId: string | null; + readonly action: "created" | "updated" | "removed"; + readonly resourceType: "connection" | "integration" | "oauth_client"; + readonly resourceOwner: "org" | "user" | null; + readonly resourceId: string; + readonly createdAt: number; + }, +): Effect.Effect => + Effect.promise(async () => { + await db.client.execute({ + sql: `INSERT INTO audit_event ( + row_id, tenant, id, actor_id, action, resource_type, resource_owner, + resource_parent, resource_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + `row-${row.id}`, + row.tenant, + row.id, + row.actorId, + row.action, + row.resourceType, + row.resourceOwner, + row.resourceType === "connection" ? "github" : null, + row.resourceId, + row.createdAt, + ], + }); + }); + /** Two users with connections under tenant A, plus a whole separate tenant B * that A's admin plane must never see. */ const seed = (db: SqliteTestFumaDb): Effect.Effect => @@ -231,6 +266,15 @@ const stubProvider = ( directory?: AdminIdentityDirectory | AdminUserDirectory, ) => Layer.succeed(AdminUsersProvider)({ + listAuditEvents: (headers, options) => + authorize(headers).pipe( + Effect.flatMap(executorFor), + Effect.flatMap((executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => listAuditEvents(admin, options, directory)), + ), + ), + ), listUsers: (headers, options) => authorize(headers).pipe( Effect.flatMap(executorFor), @@ -359,6 +403,20 @@ type UsersWithConnectionsBody = { }>; }>; }; +type AuditEventsBody = { + readonly events: ReadonlyArray<{ + readonly id: string; + readonly actorId: string | null; + readonly actorEmail: string | null; + readonly actorDisplayName: string | null; + readonly action: string; + readonly resourceType: string; + readonly resourceOwner: string | null; + readonly resourceParent: string | null; + readonly resourceId: string; + readonly createdAt: number; + }>; +}; const ORG_A = "Bearer org_a_key"; @@ -420,6 +478,75 @@ const failingDirectory: AdminIdentityDirectory = () => Effect.fail(new DirectoryUnavailable({ message: "member directory unavailable" })); describe("admin users API", () => { + it.effect("lists filtered audit events with actor identity and tenant isolation", () => + withDb((db) => + Effect.gen(function* () { + yield* insertAuditEvent(db, { + id: "aud-a-old", + tenant: TENANT_A, + actorId: USER_A1, + action: "created", + resourceType: "connection", + resourceOwner: "org", + resourceId: "shared", + createdAt: 100, + }); + yield* insertAuditEvent(db, { + id: "aud-a-new", + tenant: TENANT_A, + actorId: USER_A1, + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceId: "personal", + createdAt: 200, + }); + yield* insertAuditEvent(db, { + id: "aud-b", + tenant: TENANT_B, + actorId: USER_B1, + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceId: "other-tenant-secret-name", + createdAt: 300, + }); + + const seen: string[][] = []; + const web = yield* webHandlerFor( + stubProvider( + (tenant) => platformExecutorFor(db, tenant), + headerAuthorize, + stubUserDirectory({ seen }), + ), + ); + const response = yield* get( + web, + "/admin/audit-events?action=removed&resourceOwner=user&limit=1", + ORG_A, + ); + expect(response.status).toBe(200); + const body = yield* jsonOf(response); + expect(body.events).toEqual([ + { + id: "aud-a-new", + actorId: USER_A1, + actorEmail: A1_EMAIL_STORED, + actorDisplayName: "User A1", + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceParent: "github", + resourceId: "personal", + createdAt: 200_000, + }, + ]); + expect(seen).toEqual([[USER_A1]]); + expect(JSON.stringify(body)).not.toContain("other-tenant-secret-name"); + }), + ), + ); + it.effect("lists every user of the tenant for an authorized org caller", () => withDb((db) => Effect.gen(function* () { @@ -499,6 +626,7 @@ describe("admin users API", () => { ); for (const path of [ + "/admin/audit-events", "/admin/users", "/admin/users/with-connections", `/admin/users/${USER_A1}/connections`, @@ -570,6 +698,9 @@ describe("admin users API", () => { const member = yield* get(web, "/admin/users", "Bearer user_scoped_key"); expect(member.status, "a non-admin caller → forbidden").toBe(403); + expect((yield* get(web, "/admin/audit-events")).status).toBe(401); + expect((yield* get(web, "/admin/audit-events", "Bearer user_scoped_key")).status).toBe(403); + const memberJoined = yield* get(web, "/admin/users/with-connections", "Bearer user_key"); expect(memberJoined.status).toBe(403); const memberConnections = yield* get( @@ -1097,6 +1228,10 @@ const A_SUBJECT: AdminSubject = { /** An `ExecutorAdmin` that answers everything and records the reads it was * asked for, so a test can assert the call the filter chose. */ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ + listAuditEvents: () => { + calls.push("listAuditEvents"); + return Effect.succeed([]); + }, listSubjects: () => { calls.push("listSubjects"); return Effect.succeed([A_SUBJECT]); diff --git a/packages/core/api/src/admin/api.ts b/packages/core/api/src/admin/api.ts index 360a61b60..299dd9884 100644 --- a/packages/core/api/src/admin/api.ts +++ b/packages/core/api/src/admin/api.ts @@ -37,6 +37,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect"; import { ConnectionName, HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared"; +import { AUDIT_EVENT_ACTIONS, AUDIT_RESOURCE_TYPES } from "@executor-js/sdk"; // --------------------------------------------------------------------------- // Errors @@ -196,6 +197,24 @@ export const AdminUserResponse = Schema.Struct({ user: AdminUserWithConnections, }); +export const AdminAuditEvent = Schema.Struct({ + id: Schema.String, + actorId: Schema.NullOr(Schema.String), + actorEmail: Schema.NullOr(Schema.String), + actorDisplayName: Schema.NullOr(Schema.String), + action: Schema.Literals(AUDIT_EVENT_ACTIONS), + resourceType: Schema.Literals(AUDIT_RESOURCE_TYPES), + resourceOwner: Schema.NullOr(Owner), + resourceParent: Schema.NullOr(Schema.String), + resourceId: Schema.String, + /** Epoch milliseconds. */ + createdAt: Schema.Number, +}); + +export const AdminAuditEventsResponse = Schema.Struct({ + events: Schema.Array(AdminAuditEvent), +}); + // --------------------------------------------------------------------------- // Params / query // --------------------------------------------------------------------------- @@ -274,6 +293,22 @@ const AdminListQuery = Schema.Struct({ email: Schema.optional(Schema.String), }); +const AdminAuditListQuery = Schema.Struct({ + limit: Schema.optional( + Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })), + ), + offset: Schema.optional( + Schema.FiniteFromString.check( + Schema.isInt(), + Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), + ), + ), + actorId: Schema.optional(Schema.String), + action: Schema.optional(Schema.Literals(AUDIT_EVENT_ACTIONS)), + resourceType: Schema.optional(Schema.Literals(AUDIT_RESOURCE_TYPES)), + resourceOwner: Schema.optional(Owner), +}); + // --------------------------------------------------------------------------- // Group // --------------------------------------------------------------------------- @@ -304,6 +339,13 @@ const AdminListQuery = Schema.Struct({ * same position, and the tree matches on position, not on name. */ export const AdminUsersApi = HttpApiGroup.make("adminUsers") + .add( + HttpApiEndpoint.get("listAuditEvents", "/admin/audit-events", { + query: AdminAuditListQuery, + success: AdminAuditEventsResponse, + error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden], + }), + ) .add( HttpApiEndpoint.get("listUsers", "/admin/users", { query: AdminListQuery, diff --git a/packages/core/api/src/admin/handlers.ts b/packages/core/api/src/admin/handlers.ts index f5d6ccc8b..078983b90 100644 --- a/packages/core/api/src/admin/handlers.ts +++ b/packages/core/api/src/admin/handlers.ts @@ -1,6 +1,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest } from "effect/unstable/http"; import { Effect } from "effect"; +import type { + AdminListAuditEventsOptions, + AuditEventAction, + AuditResourceType, + Owner, +} from "@executor-js/sdk"; import { AdminUsersHttpApi } from "./api"; import { normalizeEmail } from "./reads"; @@ -34,11 +40,36 @@ const listOptions = (query: { ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), }); +const auditListOptions = (query: { + readonly limit?: number | undefined; + readonly offset?: number | undefined; + readonly actorId?: string | undefined; + readonly action?: AuditEventAction | undefined; + readonly resourceType?: AuditResourceType | undefined; + readonly resourceOwner?: Owner | undefined; +}): AdminListAuditEventsOptions => ({ + ...(query.limit === undefined ? {} : { limit: query.limit }), + ...(query.offset === undefined ? {} : { offset: query.offset }), + ...(query.actorId === undefined ? {} : { actorId: query.actorId }), + ...(query.action === undefined ? {} : { action: query.action }), + ...(query.resourceType === undefined ? {} : { resourceType: query.resourceType }), + ...(query.resourceOwner === undefined ? {} : { resourceOwner: query.resourceOwner }), +}); + export const AdminUsersHandlers = HttpApiBuilder.group( AdminUsersHttpApi, "adminUsers", (handlers) => handlers + .handle("listAuditEvents", ({ query }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AdminUsersProvider).listAuditEvents( + headers, + auditListOptions(query), + ); + }), + ) .handle("listUsers", ({ query }) => Effect.gen(function* () { const headers = yield* requestHeaders; diff --git a/packages/core/api/src/admin/reads.ts b/packages/core/api/src/admin/reads.ts index 96b6f2bbd..36ad52ff4 100644 --- a/packages/core/api/src/admin/reads.ts +++ b/packages/core/api/src/admin/reads.ts @@ -16,6 +16,7 @@ import { Effect } from "effect"; import type { AdminConnection, + AdminListAuditEventsOptions, AdminSubject, AdminSubjectWithConnections, Executor, @@ -26,6 +27,7 @@ import { AdminUserNotFound, AdminUsersError, type AdminUserConnectionsResponse, + type AdminAuditEventsResponse, type AdminUserResponse, type AdminUsersResponse, type AdminUsersWithConnectionsResponse, @@ -145,6 +147,41 @@ const resolveIdentities = ( const ABSENT_IDENTITY: AdminUserIdentity = { email: null, displayName: null }; +export const listAuditEvents = ( + admin: ExecutorAdmin, + options: AdminListAuditEventsOptions, + directory?: AdminIdentityDirectory | AdminUserDirectory, +): Effect.Effect => + Effect.gen(function* () { + const events = yield* admin + .listAuditEvents(options) + .pipe(Effect.mapError(readFailed("audit events"))); + const actorIds = [ + ...new Set(events.flatMap((event) => (event.actorId === null ? [] : [event.actorId]))), + ]; + const identities = yield* resolveIdentities(asDirectory(directory).identities, actorIds); + return { + events: events.map((event) => { + const identity = + event.actorId === null + ? ABSENT_IDENTITY + : (identities.get(event.actorId) ?? ABSENT_IDENTITY); + return { + id: event.id, + actorId: event.actorId, + actorEmail: identity.email, + actorDisplayName: identity.displayName, + action: event.action, + resourceType: event.resourceType, + resourceOwner: event.resourceOwner, + resourceParent: event.resourceParent, + resourceId: event.resourceId, + createdAt: event.createdAt.getTime(), + }; + }), + }; + }); + /** * `AdminSubject` → the public `AdminUser` shape. * diff --git a/packages/core/api/src/admin/service.ts b/packages/core/api/src/admin/service.ts index b1d9baba9..11ec8c37d 100644 --- a/packages/core/api/src/admin/service.ts +++ b/packages/core/api/src/admin/service.ts @@ -17,6 +17,7 @@ // --------------------------------------------------------------------------- import { Context, type Effect } from "effect"; +import type { AdminListAuditEventsOptions } from "@executor-js/sdk"; import { type AdminUserNotFound, @@ -24,6 +25,7 @@ import { type AdminUsersForbidden, type AdminUsersUnauthorized, AdminUserResponse, + AdminAuditEventsResponse, AdminUsersResponse, AdminUserConnectionsResponse, AdminUsersWithConnectionsResponse, @@ -41,6 +43,7 @@ export interface AdminUsersListOptions { } type User = typeof AdminUserResponse.Type; +type AuditEvents = typeof AdminAuditEventsResponse.Type; type Users = typeof AdminUsersResponse.Type; type UserConnections = typeof AdminUserConnectionsResponse.Type; type UsersWithConnections = typeof AdminUsersWithConnectionsResponse.Type; @@ -52,6 +55,10 @@ type Authorized = Effect.Effect< >; export interface AdminUsersProviderShape { + readonly listAuditEvents: ( + headers: AdminUsersHeaders, + options: AdminListAuditEventsOptions, + ) => Authorized; readonly listUsers: ( headers: AdminUsersHeaders, options: AdminUsersListOptions, diff --git a/packages/core/api/src/client.ts b/packages/core/api/src/client.ts index a46ec456f..4e5e51d96 100644 --- a/packages/core/api/src/client.ts +++ b/packages/core/api/src/client.ts @@ -20,6 +20,8 @@ export { AdminUsersError, AdminUsersForbidden, AdminUsersUnauthorized, + AdminAuditEvent, + AdminAuditEventsResponse, AdminUser, AdminUserConnection, AdminUserWithConnections, diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index c93e983cb..cd7bb7698 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -22,6 +22,7 @@ import { IntegrationSlug, InternalError, InvalidConnectionInputError, + OrgWriteDeniedError, OAuthClientSlug, Owner, ProviderItemId, @@ -195,6 +196,7 @@ export const ConnectionsApi = HttpApiGroup.make("connections") IntegrationNotFound, CredentialProviderNotRegistered, InvalidConnectionInput, + OrgWriteDeniedError, ], }), ) @@ -210,14 +212,14 @@ 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( diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 6ba0b8b15..1f05965d5 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -69,6 +69,8 @@ export { AdminUsersForbidden, AdminUsersUnauthorized, AdminUserNotFound, + AdminAuditEvent, + AdminAuditEventsResponse, AdminUser, AdminUserConnection, AdminUserWithConnections, diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts index 6700c7a31..d5140d2dc 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 96e76a26c..f00300811 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, } from "@executor-js/sdk/shared"; @@ -275,14 +276,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( @@ -296,14 +297,14 @@ 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( diff --git a/packages/core/api/src/policies/api.ts b/packages/core/api/src/policies/api.ts index a3b9f76de..5f7915265 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.ts b/packages/core/api/src/server.ts index 104d841c3..0ee1a844b 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -33,6 +33,7 @@ export { export { AdminUsersHandlers } from "./admin/handlers"; export { platformViewOf, + listAuditEvents as listAdminAuditEvents, listUsers as listAdminUsers, listUsersWithConnections as listAdminUsersWithConnections, listUserConnections as listAdminUserConnections, diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts index b3e6749b8..db64b2d57 100644 --- a/packages/core/api/src/server/execution-stack-middleware.ts +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -239,6 +239,9 @@ export const makeExecutionStackMiddleware = < resolved.accountId, resolved.organizationId, resolved.organizationName, + // A plain member binds with workspace writes denied; an admin — + // or a host with no role model (`orgRole` absent) — binds allowed. + { orgWrites: resolved.orgRole === "member" ? "denied" : "allowed" }, ).pipe( Effect.provide(options.stackLayer), Effect.provideService(RequestWebOrigin, { diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index d951f3b81..dd1e2bdf3 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -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?: "allowed" | "denied"; + }, ): 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 04e4f2310..ecc232d17 100644 --- a/packages/core/api/src/server/identity.ts +++ b/packages/core/api/src/server/identity.ts @@ -48,6 +48,17 @@ export interface Principal { readonly name: string | null; readonly avatarUrl: string | null; readonly roles: readonly string[]; + /** + * The member's NORMALIZED workspace role, when the host resolves one: + * `"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"`). ABSENT means the host + * has no role model (local's single user, test fakes) and the middleware + * binds the executor with workspace writes allowed — hosts that DO + * distinguish roles must always set it. + */ + readonly orgRole?: "admin" | "member"; } /** diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3b9302fac..e9f4bed35 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -48,7 +48,12 @@ export const makeMcpBuildServer = principal.accountId, principal.organizationId, principal.organizationName, - { mcpResource: options?.resource }, + { + mcpResource: options?.resource, + // A plain member binds with workspace writes denied; an admin — or + // a host with no role model (`orgRole` absent) — binds allowed. + orgWrites: principal.orgRole === "member" ? "denied" : "allowed", + }, ).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 9e6013d23..36344e9cc 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -236,7 +236,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?: "allowed" | "denied"; + }, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { const { db, blobs } = yield* DbProvider.asEffect(); @@ -297,6 +303,7 @@ export const makeScopedExecutor = < fetch: hostedFetch, onIntegrationChange: config.onIntegrationChange, onElicitation: "accept-all", + ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), redirectUri, oauthCallbackStateOrgSlug: orgSlug, firstPartyOAuthClients: config.firstPartyOAuthClients, diff --git a/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts b/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts index 212a7258f..185b18bb5 100644 --- a/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts +++ b/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts @@ -181,13 +181,19 @@ export async function introspectSchema( let col: AnyColumn; if (isPrimaryKey) { - if (!columnType.startsWith("varchar") && columnType !== "uuid") + if ( + !columnType.startsWith("varchar") && + columnType !== "string" && + columnType !== "uuid" + ) throw new Error( - `ID column only supports varchar and uuid at the moment, found ${columnType}.` + `ID column only supports string, varchar, and uuid at the moment, found ${columnType}.` ); if (columnType === "uuid") { col = idColumn(dbColumn.name, "uuid"); + } else if (columnType === "string") { + col = idColumn(dbColumn.name, "string"); } else { col = idColumn(dbColumn.name, columnType as `varchar(${number})`); } diff --git a/packages/core/fumadb/src/schema/create.ts b/packages/core/fumadb/src/schema/create.ts index 98dff5286..dd8597e12 100644 --- a/packages/core/fumadb/src/schema/create.ts +++ b/packages/core/fumadb/src/schema/create.ts @@ -344,7 +344,7 @@ type DefaultFunction = | (Type extends keyof DefaultFunctionMap ? DefaultFunctionMap[Type] : never) | (() => TypeMap[Type]); -type IdColumnType = `varchar(${number})` | "uuid"; +type IdColumnType = `varchar(${number})` | "string" | "uuid"; export type TypeMap = { string: string; diff --git a/packages/core/fumadb/test/uuid.test.ts b/packages/core/fumadb/test/uuid.test.ts index 48bce926e..2eb5fd905 100644 --- a/packages/core/fumadb/test/uuid.test.ts +++ b/packages/core/fumadb/test/uuid.test.ts @@ -10,6 +10,12 @@ test("idColumn accepts uuid type", () => { expect(col.id).toBe(true); }); +test("idColumn accepts unbounded string type", () => { + const col = idColumn("id", "string").defaultTo$("auto"); + expect(col.type).toBe("string"); + expect(col.id).toBe(true); +}); + test("column accepts uuid type", () => { const col = column("token", "uuid"); expect(col.type).toBe("uuid"); @@ -90,6 +96,21 @@ test("Drizzle SQLite generates UUID schema correctly", () => { expect(generated).toContain("primaryKey()"); }); +test("Drizzle PostgreSQL generates a text primary id correctly", () => { + const stringIdSchema = schema({ + version: "1.0.0", + tables: { + audit: table("audit", { + id: idColumn("id", "string").defaultTo$("auto"), + }), + }, + }); + + const generated = Drizzle.generateSchema(stringIdSchema, "postgresql"); + expect(generated).toContain('text("id")'); + expect(generated).toContain("primaryKey()"); +}); + test("TypeORM generates UUID schema correctly", () => { const generated = TypeORM.generateSchema(uuidSchema, "postgresql"); diff --git a/packages/core/sdk/src/audit-events.test.ts b/packages/core/sdk/src/audit-events.test.ts new file mode 100644 index 000000000..3f39bfb72 --- /dev/null +++ b/packages/core/sdk/src/audit-events.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { createExecutor, type ExecutorAdmin } from "./executor"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ProviderItemId, + ProviderKey, + Tenant, +} from "./ids"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestConfig } from "./testing"; + +const INTEGRATION = IntegrationSlug.make("example"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +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 auditPlugin = definePlugin(() => ({ + id: "audit-test" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEGRATION, + description: "Example", + config: {}, + }), + }), +}))(); + +const requireAdmin = (admin: ExecutorAdmin | undefined) => + admin === undefined ? Effect.die("expected a platform admin view") : Effect.succeed(admin); + +const setup = () => + Effect.gen(function* () { + const config = makeTestConfig({ + tenant: "audit-tenant", + subject: "actor-123", + plugins: [auditPlugin] as const, + }); + const executor = yield* createExecutor(config); + const platformExecutor = yield* createExecutor({ + tenant: config.tenant, + db: config.testDb.db, + platformView: true, + onElicitation: "accept-all", + }); + const admin = yield* requireAdmin(platformExecutor.admin); + yield* Effect.addFinalizer(() => + executor + .close() + .pipe( + Effect.andThen(platformExecutor.close()), + Effect.andThen(Effect.promise(() => config.testDb.close())), + Effect.ignore, + ), + ); + return { executor, admin, db: config.testDb.db }; + }); + +describe("admin audit events", () => { + it.effect("records successful lifecycle changes with actor, scope, and safe identifiers", () => + Effect.gen(function* () { + const { executor, admin } = yield* setup(); + yield* executor["audit-test"].seed(); + + const shared = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEGRATION, + template: TEMPLATE, + value: "SECRET-workspace-token", + }); + const personal = yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: INTEGRATION, + template: TEMPLATE, + value: "SECRET-personal-token", + }); + yield* executor.connections.update( + { owner: shared.owner, integration: shared.integration, name: shared.name }, + { description: "renamed" }, + ); + yield* executor.connections.remove({ + owner: personal.owner, + integration: personal.integration, + name: personal.name, + }); + + const client = OAuthClientSlug.make("workspace-app"); + const clientInput = { + owner: "org" as const, + slug: client, + authorizationUrl: "https://example.test/authorize", + tokenUrl: "https://example.test/token", + grant: "authorization_code" as const, + clientId: "client-id", + clientSecret: "SECRET-client-secret", + }; + yield* executor.oauth.createClient(clientInput); + yield* executor.oauth.createClient({ ...clientInput, clientId: "updated-client-id" }); + yield* executor.oauth.removeClient("org", client); + + yield* executor.integrations.update(INTEGRATION, { name: "Renamed" }); + yield* executor.integrations.remove(INTEGRATION); + + const events = yield* admin.listAuditEvents(); + expect(events).toHaveLength(10); + expect(new Set(events.map((event) => event.actorId))).toEqual(new Set(["actor-123"])); + expect( + events.map(({ action, resourceType, resourceOwner, resourceParent, resourceId }) => ({ + action, + resourceType, + resourceOwner, + resourceParent, + resourceId, + })), + ).toEqual( + expect.arrayContaining([ + { + action: "created", + resourceType: "connection", + resourceOwner: "org", + resourceParent: "example", + resourceId: "shared", + }, + { + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceParent: "example", + resourceId: "personal", + }, + { + action: "updated", + resourceType: "oauth_client", + resourceOwner: "org", + resourceParent: null, + resourceId: "workspace-app", + }, + { + action: "removed", + resourceType: "integration", + resourceOwner: null, + resourceParent: null, + resourceId: "example", + }, + ]), + ); + + const serialized = JSON.stringify(events); + expect(serialized).not.toContain("SECRET-"); + expect(serialized).not.toContain("client-id"); + expect(serialized).not.toContain("authorizationUrl"); + }).pipe(Effect.scoped), + ); + + it.effect("filters, pages, and isolates the tenant", () => + Effect.gen(function* () { + const { executor, admin, db } = yield* setup(); + yield* executor["audit-test"].seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEGRATION, + template: TEMPLATE, + value: "workspace-token", + }); + yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: INTEGRATION, + template: TEMPLATE, + value: "personal-token", + }); + + const orgEvents = yield* admin.listAuditEvents({ resourceOwner: "org" }); + expect(orgEvents).toHaveLength(1); + expect(orgEvents[0]).toMatchObject({ resourceType: "connection", resourceId: "shared" }); + expect(yield* admin.listAuditEvents({ resourceType: "connection", limit: 1 })).toHaveLength( + 1, + ); + expect(yield* admin.listAuditEvents({ resourceType: "connection", offset: 1 })).toHaveLength( + 1, + ); + + const otherPlatform = yield* createExecutor({ + tenant: Tenant.make("other-tenant"), + db, + platformView: true, + onElicitation: "accept-all", + }); + yield* Effect.addFinalizer(() => otherPlatform.close().pipe(Effect.ignore)); + const otherAdmin = yield* requireAdmin(otherPlatform.admin); + expect(yield* otherAdmin.listAuditEvents()).toEqual([]); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/core/sdk/src/audit.ts b/packages/core/sdk/src/audit.ts new file mode 100644 index 000000000..20f1b48f8 --- /dev/null +++ b/packages/core/sdk/src/audit.ts @@ -0,0 +1,40 @@ +import type { Owner } from "./ids"; + +export const AUDIT_EVENT_ACTIONS = ["created", "updated", "removed"] as const; +export type AuditEventAction = (typeof AUDIT_EVENT_ACTIONS)[number]; + +export const AUDIT_RESOURCE_TYPES = ["connection", "integration", "oauth_client"] as const; +export type AuditResourceType = (typeof AUDIT_RESOURCE_TYPES)[number]; + +/** A durable, tenant-scoped record of a user-intent configuration mutation. + * Credential values and provider item ids are deliberately never recorded. */ +export interface AdminAuditEvent { + readonly id: string; + readonly actorId: string | null; + readonly action: AuditEventAction; + readonly resourceType: AuditResourceType; + readonly resourceOwner: Owner | null; + /** Parent namespace for a resource. Connections use their integration slug. */ + readonly resourceParent: string | null; + /** The resource's own stable identifier (connection name, integration slug, + * or OAuth-client slug). */ + readonly resourceId: string; + readonly createdAt: Date; +} + +export interface AdminListAuditEventsOptions { + readonly limit?: number; + readonly offset?: number; + readonly actorId?: string; + readonly action?: AuditEventAction; + readonly resourceType?: AuditResourceType; + readonly resourceOwner?: Owner; +} + +export interface AuditEventInput { + readonly action: AuditEventAction; + readonly resourceType: AuditResourceType; + readonly resourceOwner?: Owner | null; + readonly resourceParent?: string | null; + readonly resourceId: string; +} diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index b03adf5df..a3e559df8 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -57,11 +57,12 @@ const tenantExecutorTable = ( name: string, columns: TColumns, uniqueKey: readonly string[], + keyStorage: "varchar(255)" | "string" = "varchar(255)", ) => { const out = table(name, { ...columns, - row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"), - tenant: keyColumn("tenant"), + row_id: idColumn("row_id", keyStorage).defaultTo$("auto"), + tenant: column("tenant", keyStorage), }); out.unique(`${name}_uidx`, [...uniqueKey]); return out.policy({ @@ -197,6 +198,31 @@ export const coreTables = defineTables({ ["tenant", "external_id"], ), + // Append-only configuration audit history. Tenant-scoped so the platform + // view can read every actor's events without widening any credential-bearing + // owner-scoped table. Rows contain identifiers only — never credential + // values, provider item ids, OAuth tokens, or free-form descriptions. + audit_event: tenantExecutorTable( + "audit_event", + { + id: textColumn("id"), + actor_id: nullableTextColumn("actor_id"), + action: textColumn("action"), + resource_type: textColumn("resource_type"), + resource_owner: nullableTextColumn("resource_owner"), + resource_parent: nullableTextColumn("resource_parent"), + resource_id: textColumn("resource_id"), + created_at: dateColumn("created_at"), + }, + // The unique index doubles as the newest-first admin read index. `id` is + // globally unique in practice and remains the final tie-breaker for events + // written in the same millisecond. + ["tenant", "created_at", "id"], + // Audit identifiers are unbounded text by design. This table is currently + // backed by PostgreSQL/SQLite, both of which index text values directly. + "string", + ), + // THE saved credential, one per (owner, integration, name). Resolves each named // input via `provider` + the `item_ids` map (variable → provider item id). A // single-secret connection is `{ "token": }`; an apiKey method with two @@ -431,6 +457,7 @@ export type CoreSchema = typeof coreTables; export type IntegrationRow = FumaRow; export type SubjectRow = FumaRow; +export type AuditEventRow = FumaRow; export type ConnectionRow = FumaRow; export type OAuthClientRow = FumaRow; export type OAuthSessionRow = FumaRow; diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 8a9e5e732..f25b53db2 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -143,6 +143,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.ts b/packages/core/sdk/src/executor.ts index cdad0efe0..928440f6d 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -37,11 +37,19 @@ import { type ConnectionRow, type CoreSchema, type IntegrationRow, + type AuditEventRow, type OAuthClientRow, type ToolInvocationRow, type ToolRow, type ToolPolicyRow, } from "./core-schema"; +import type { + AdminAuditEvent, + AdminListAuditEventsOptions, + AuditEventInput, + AuditEventAction, + AuditResourceType, +} from "./audit"; import { ElicitationDeclinedError, ElicitationResponse, @@ -72,6 +80,7 @@ import { InvalidConnectionInputError, IntegrationRemovalNotAllowedError, NoHandlerError, + OrgWriteDeniedError, PluginNotLoadedError, ToolBlockedError, ToolInvocationError, @@ -289,10 +298,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; @@ -317,7 +329,7 @@ export type Executor = { readonly set: ( slug: IntegrationSlug, spec: HealthCheckSpec | null, - ) => Effect.Effect; + ) => Effect.Effect; }; }; @@ -329,6 +341,7 @@ export type Executor = { | IntegrationNotFoundError | CredentialProviderNotRegisteredError | InvalidConnectionInputError + | OrgWriteDeniedError | StorageFailure >; readonly list: (filter?: { @@ -341,10 +354,10 @@ 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< @@ -391,9 +404,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; }; @@ -543,6 +562,11 @@ const normalizeAdminPaging = ( }; export interface ExecutorAdmin { + /** Newest-first tenant audit history. Identifiers only: no credential + * material or free-form configuration is exposed. */ + readonly listAuditEvents: ( + options?: AdminListAuditEventsOptions, + ) => Effect.Effect; /** One page of subjects under the tenant, oldest first (stable: ties break on * `external_id`). ALWAYS bounded: no arguments means * {@link ADMIN_DEFAULT_PAGE_SIZE} rows from offset 0, and `limit` is clamped @@ -709,6 +733,25 @@ export interface ExecutorConfig => + config.orgWrites === "denied" && (owner === undefined || owner === "org") + ? Effect.fail(new OrgWriteDeniedError()) + : Effect.void; + // Built-in core-tools plugin: agent-facing static tools over the v2 surface. const plugins: readonly AnyPlugin[] = config.coreTools ? ([ @@ -1667,6 +1721,24 @@ export const createExecutor = (effect: Effect.Effect) => fuma.transaction(effect); + const recordAuditEvent = (input: AuditEventInput): Effect.Effect => { + const createdAt = new Date(); + const id = `aud_${createdAt.getTime().toString(36)}_${Math.random().toString(36).slice(2, 12)}`; + return core + .create("audit_event", { + tenant, + id, + actor_id: subject, + action: input.action, + resource_type: input.resourceType, + resource_owner: input.resourceOwner ?? null, + resource_parent: input.resourceParent ?? null, + resource_id: input.resourceId, + created_at: createdAt, + }) + .pipe(Effect.asVoid); + }; + // Runtime-observed output shapes ("muscle memory"): learned on the // execute success path, served by tools.schema when a tool declares no // output schema. Backed by plugin_storage under a reserved system id. @@ -2595,7 +2667,7 @@ export const createExecutor = => + ): Effect.Effect => transaction( Effect.gen(function* () { const now = new Date(); @@ -2616,6 +2688,10 @@ 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, + }); + yield* recordAuditEvent({ + action: "updated", + resourceType: "integration", + resourceId: String(slug), + }); + }), + ); 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 }); @@ -2678,9 +2767,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) { @@ -2707,6 +2800,11 @@ export const createExecutor = b("slug", "=", String(slug)), }); + yield* recordAuditEvent({ + action: "removed", + resourceType: "integration", + resourceId: String(slug), + }); return existing.plugin_id; }), ).pipe( @@ -2768,8 +2866,9 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(); const row = yield* findIntegrationRow(slug); if (!row) return yield* new IntegrationNotFoundError({ slug }); yield* core.updateMany("integration", { @@ -3009,9 +3108,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. @@ -3149,6 +3250,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); - }); + yield* recordAuditEvent({ + action: "updated", + resourceType: "connection", + resourceOwner: ref.owner, + resourceParent: String(ref.integration), + resourceId: String(ref.name), + }); + 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({ @@ -3445,6 +3571,13 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(input.owner); if (!isValidPattern(input.pattern)) { return yield* new StorageError({ message: `Invalid tool policy pattern: ${input.pattern}`, @@ -4303,8 +4437,9 @@ export const createExecutor = => + ): Effect.Effect => 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}`, @@ -4328,10 +4463,16 @@ export const createExecutor = => - core.deleteMany("tool_policy", { - where: (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)), - }); + const policiesRemove = ( + input: RemoveToolPolicyInput, + ): Effect.Effect => + guardOrgWrite(input.owner).pipe( + Effect.andThen( + core.deleteMany("tool_policy", { + where: (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)), + }), + ), + ); const policiesResolve = ( address: ToolAddress, @@ -4863,6 +5004,8 @@ export const createExecutor = ownedKeys(owner), + guardOrgWrite: (owner: Owner) => guardOrgWrite(owner), + recordAuditEvent, defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), connectionNameTaken: (ref) => findConnectionRow(ref).pipe(Effect.map((row) => row !== null)), @@ -5126,6 +5269,44 @@ export const createExecutor = ({ + id: row.id, + actorId: row.actor_id == null ? null : String(row.actor_id), + action: row.action as AuditEventAction, + resourceType: row.resource_type as AuditResourceType, + resourceOwner: row.resource_owner == null ? null : (row.resource_owner as Owner), + resourceParent: row.resource_parent == null ? null : String(row.resource_parent), + resourceId: String(row.resource_id), + createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + }); + + const listAuditEvents = ( + options?: AdminListAuditEventsOptions, + ): Effect.Effect => { + const { limit, offset } = normalizeAdminPaging(options); + return platformCore + .findMany("audit_event", { + where: (b: AnyCb) => + b.and( + options?.actorId === undefined ? true : b("actor_id", "=", options.actorId), + options?.action === undefined ? true : b("action", "=", options.action), + options?.resourceType === undefined + ? true + : b("resource_type", "=", options.resourceType), + options?.resourceOwner === undefined + ? true + : b("resource_owner", "=", options.resourceOwner), + ), + orderBy: [ + ["created_at", "desc"], + ["id", "desc"], + ], + limit, + offset, + }) + .pipe(Effect.map((rows) => rows.map(rowToAdminAuditEvent))); + }; + const listSubjects = ( options?: AdminListSubjectsOptions, ): Effect.Effect => { @@ -5237,6 +5418,7 @@ export const createExecutor = 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; @@ -471,10 +474,10 @@ 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; diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index ab964eb5d..c733203d7 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -18,7 +18,9 @@ import { Duration, Effect, Layer, Match, Option, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { connectionIdentifier } from "./connection-name-identifier"; +import type { AuditEventInput } from "./audit"; import type { Connection } from "./connection"; +import type { OrgWriteDeniedError } from "./errors"; import type { IFumaClient, StorageFailure } from "./fuma-runtime"; import { StorageError } from "./fuma-runtime"; import { @@ -186,6 +188,11 @@ export interface OAuthServiceDeps { 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 recordAuditEvent: (input: AuditEventInput) => Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ readonly mintOAuthConnection: ( @@ -800,7 +807,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 @@ -811,6 +818,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + yield* deps.guardOrgWrite(input.owner); yield* validateClientEndpoints(input, deps.endpointUrlPolicy); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), @@ -839,42 +847,54 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { yield* provider.set(ProviderItemId.make(clientSecretItemIdValue), input.clientSecret); } - 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, - 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, + 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)); + 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, + resource: input.resource ?? null, + origin_kind: input.origin?.kind ?? "manual", + 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, + }), + ); + yield* deps.recordAuditEvent({ + action: existing ? "updated" : "created", + resourceType: "oauth_client", + resourceOwner: input.owner, + resourceId: String(input.slug), + }); }), ); return input.slug; @@ -894,7 +914,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 @@ -905,16 +928,35 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } - 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); + yield* deps.guardOrgWrite(owner); + const removed = yield* deps.fuma.transaction( + Effect.gen(function* () { + const existing = yield* deps.fuma.use("oauth_client.findForRemoval", (db) => + looseDb(db).findFirst("oauth_client", { + where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), + }), + ); + 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); + if (existing) { + yield* deps.recordAuditEvent({ + action: "removed", + resourceType: "oauth_client", + resourceOwner: owner, + resourceId: String(slug), + }); + } + return existing !== null; + }), + ); // Best-effort: drop the secret from the provider so it isn't orphaned. const provider = deps.defaultWritableProvider(); - if (provider?.delete) { + if (removed && provider?.delete) { yield* provider .delete(ProviderItemId.make(clientSecretItemId(owner, slug))) .pipe(Effect.catch(() => Effect.void)); @@ -1092,7 +1134,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const registerDynamicClient = ( input: RegisterDynamicClientInput, - ): Effect.Effect => + ): Effect.Effect< + OAuthClientSlug, + OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure + > => Effect.gen(function* () { const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); // Resolved before the reuse decision: a persisted client registered with @@ -1302,8 +1347,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) => 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 000000000..dc37a7156 --- /dev/null +++ b/packages/core/sdk/src/org-writes.test.ts @@ -0,0 +1,271 @@ +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 { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestConfig } from "./testing"; + +// --------------------------------------------------------------------------- +// `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)), + 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("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" }); + yield* member.connections.remove(mineRef); + + 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.remove(ref)); + }).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("keeps the register REPLACE arm open (config rewrites converge for members)", () => + Effect.gen(function* () { + const { member } = yield* setup(); + // The admin already registered `vercel`; re-registering the same slug on + // the denied binding is the replace arm and must succeed — this is the + // path catalog rebuilds and legacy healing converge through. + yield* member.demo.seed(); + const row = yield* member.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), + ); +}); + +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.ts b/packages/core/sdk/src/plugin.ts index c30a3f43e..f8d462a91 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -47,6 +47,7 @@ import type { IntegrationNotFoundError, IntegrationRemovalNotAllowedError, InvalidConnectionInputError, + OrgWriteDeniedError, } from "./errors"; import type { OAuthService } from "./oauth-client"; import type { CredentialProvider, ProviderEntry } from "./provider"; @@ -162,8 +163,12 @@ export interface PluginCtx { readonly core: { readonly integrations: { - /** Register / replace this plugin's integration in the catalog. */ - readonly register: (input: RegisterIntegrationInput) => Effect.Effect; + /** Register / replace this plugin's integration in the catalog. A NEW + * row is a workspace-level change gated by the executor's `orgWrites` + * binding; replacing an existing row stays open at every role. */ + readonly register: ( + input: RegisterIntegrationInput, + ) => Effect.Effect; readonly update: ( slug: IntegrationSlug, patch: { @@ -171,21 +176,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; @@ -194,9 +202,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; }; }; @@ -210,6 +224,7 @@ export interface PluginCtx { | IntegrationNotFoundError | CredentialProviderNotRegisteredError | InvalidConnectionInputError + | OrgWriteDeniedError | StorageFailure >; readonly list: (filter?: { @@ -221,10 +236,10 @@ 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< diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index c0dcfc5de..1f1a3be2e 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -59,6 +59,7 @@ export { IntegrationNotFoundError, IntegrationAlreadyExistsError, IntegrationRemovalNotAllowedError, + OrgWriteDeniedError, ConnectionNotFoundError, InvalidConnectionInputError, CredentialProviderNotRegisteredError, diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index f452b94db..c7ecf52ee 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -125,6 +125,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 = ( @@ -164,6 +168,7 @@ export const makeTestConfig = ; diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 363038ef5..d5fced917 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/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 6e162d29d..6ffa353c7 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, @@ -1020,7 +1021,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 2de2fb122..0e10956cb 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"; @@ -153,6 +154,7 @@ export const McpGroup = HttpApiGroup.make("mcp") McpConnectionError, McpToolDiscoveryError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ], }), ) @@ -160,7 +162,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( @@ -175,7 +177,7 @@ export const McpGroup = HttpApiGroup.make("mcp") params: SlugParams, payload: ConfigureServerPayload, success: ConfigureServerResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ) .add( @@ -183,6 +185,6 @@ export const McpGroup = HttpApiGroup.make("mcp") params: SlugParams, payload: ConfigureAuthPayload, success: ConfigureAuthResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ); diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index 13c2252af..ad52292b7 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/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index d401f896c..b58f571a2 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -23,6 +23,7 @@ import { type IntegrationConfig, type IntegrationRecord, type OAuthClientSummary, + type OrgWriteDeniedError, type Owner, type PluginCtx, type StaticToolSchema, @@ -1703,12 +1704,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< @@ -1718,9 +1724,9 @@ export interface McpPluginExtension { readonly configureServer: ( slug: string, config: McpIntegrationConfigType, - ) => Effect.Effect; + ) => Effect.Effect; readonly configureAuth: ( slug: string, input: McpConfigureAuthInput, - ) => Effect.Effect; + ) => Effect.Effect; } diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 0b8077bc4..dcdf43427 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/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index d92a2ae14..2f5d43dce 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"; @@ -166,6 +167,7 @@ export interface OpenApiPluginExtension { | OpenApiOAuthError | OpenApiSpecOverrideError | IntegrationAlreadyExistsError + | OrgWriteDeniedError | StorageFailure >; /** Re-resolve the integration's spec (from its stored source URL, or the @@ -181,9 +183,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. */ @@ -195,7 +198,7 @@ export interface OpenApiPluginExtension { readonly configure: ( slug: string, input: OpenApiConfigureInput, - ) => Effect.Effect; + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -1165,7 +1168,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/api/admin-atoms.tsx b/packages/react/src/api/admin-atoms.tsx index e72bac781..753090dc2 100644 --- a/packages/react/src/api/admin-atoms.tsx +++ b/packages/react/src/api/admin-atoms.tsx @@ -20,12 +20,26 @@ import { ReactivityKey } from "./reactivity-keys"; * 1..500 bound; the joined endpoint reads per-user connections, so a modest * page keeps that join cheap. */ export const ADMIN_USERS_PAGE_SIZE = 25; +export const ADMIN_AUDIT_EVENTS_PAGE_SIZE = 50; export interface AdminUsersPage { readonly limit: number; readonly offset: number; } +export interface AdminAuditEventsPage { + readonly limit: number; + readonly offset: number; +} + +export const adminAuditEventsAtom = Atom.family((page: AdminAuditEventsPage) => + AdminApiClient.query("adminUsers", "listAuditEvents", { + query: { limit: page.limit + 1, offset: page.offset }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.adminUsers], + }), +); + /** * One page of users joined with their connections — what the list renders. * diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index f0ec361da..53ae72f78 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -19,6 +19,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, @@ -86,6 +87,7 @@ function AccountRow(props: { * reconnect to grant the newly-needed access (e.g. after a service was added). */ readonly needsReconsent: boolean; readonly showOwnerLabel: boolean; + readonly canReconnect: boolean; readonly onEdit: () => void; readonly onReconnect: () => void; readonly onRemove: () => void; @@ -213,9 +215,11 @@ function AccountRow(props: { Edit - - Reconnect - + {props.canReconnect ? ( + + Reconnect + + ) : null} Remove @@ -230,6 +234,7 @@ function OwnerAccounts(props: { readonly integration: IntegrationSlug; readonly owner: Owner; readonly showOwnerLabels: boolean; + readonly canCreateConnections: boolean; readonly methods: readonly AuthMethod[]; readonly onEdit: (connection: Connection) => void; readonly onDcrReconnect: (connection: Connection) => void; @@ -392,6 +397,7 @@ function OwnerAccounts(props: { connection={connection} needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)} showOwnerLabel={props.showOwnerLabels} + canReconnect={props.canCreateConnections || reconnectMode(connection) !== "oauth"} onEdit={() => props.onEdit(connection)} onReconnect={() => void handleReconnect(connection)} onRemove={() => setRemovingConnection(connection)} @@ -453,13 +459,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 @@ -531,14 +539,8 @@ export function AccountsSection(props: {

Connections

- {!showEmptyState ? ( - ) : null} @@ -553,17 +555,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}
) : (
@@ -573,6 +573,7 @@ export function AccountsSection(props: { integration={integration} owner={owner} showOwnerLabels={ownerDisplay.showOwnerLabels} + canCreateConnections={owner === "user" || canCreateWorkspaceConnections} methods={methods} onEdit={setEditingConnection} onDcrReconnect={(connection: Connection) => { diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index c2a7a5ac9..dce23319a 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -48,10 +48,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, @@ -1188,16 +1189,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 @@ -1658,6 +1668,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), @@ -2448,7 +2461,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.tsx b/packages/react/src/components/oauth-client-form.tsx index 9d73c881d..966961ae4 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -14,12 +14,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"; @@ -160,9 +161,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 @@ -172,9 +174,9 @@ 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. + // 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 [owner, setOwner] = useState( normalizeConnectionOwner(fixedOwner ?? "org", ownerOptions), ); diff --git a/packages/react/src/lib/admin-access.test.ts b/packages/react/src/lib/admin-access.test.ts index 47a3cc7df..2bc6d0f8f 100644 --- a/packages/react/src/lib/admin-access.test.ts +++ b/packages/react/src/lib/admin-access.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { isTenantAdminMember, type TenantMemberRow } from "./admin-access"; +import { + canCreateWorkspaceConnectionsForHost, + isTenantAdminMember, + type TenantMemberRow, +} from "./admin-access"; const member = (overrides: Partial = {}): TenantMemberRow => ({ role: "member", @@ -50,3 +54,14 @@ 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); + }); +}); diff --git a/packages/react/src/lib/admin-access.ts b/packages/react/src/lib/admin-access.ts index 433b4aa68..5bbf59810 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/lib/admin-users-display.test.ts b/packages/react/src/lib/admin-users-display.test.ts index fad73a339..45f82feba 100644 --- a/packages/react/src/lib/admin-users-display.test.ts +++ b/packages/react/src/lib/admin-users-display.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; import type { IntegrationSlug } from "@executor-js/sdk/shared"; import { + adminAuditActorLabel, + adminAuditResourceLabel, + adminAuditScopeLabel, adminUserCopyableEmail, adminUserTitle, connectionHealthStatus, @@ -18,6 +21,41 @@ import { type AdminConnectionRow, } from "./admin-users-display"; +describe("audit activity display", () => { + it("names actors without inventing an identity for system events", () => { + expect( + adminAuditActorLabel({ + actorId: "user_1", + actorEmail: "admin@example.test", + actorDisplayName: "Admin", + }), + ).toBe("admin@example.test"); + expect(adminAuditActorLabel({ actorId: null, actorEmail: null, actorDisplayName: null })).toBe( + "System", + ); + }); + + it("renders safe resource identifiers and personal versus workspace scope", () => { + expect( + adminAuditResourceLabel({ + resourceType: "connection", + resourceParent: "github", + resourceId: "main", + }), + ).toBe("Connection: github / main"); + expect( + adminAuditResourceLabel({ + resourceType: "oauth_client", + resourceParent: null, + resourceId: "workspace-app", + }), + ).toBe("OAuth app: workspace-app"); + expect(adminAuditScopeLabel("user")).toBe("Personal"); + expect(adminAuditScopeLabel("org")).toBe("Workspace"); + expect(adminAuditScopeLabel(null)).toBe("Workspace"); + }); +}); + const slug = (value: string): IntegrationSlug => value as IntegrationSlug; /** A catalog row for a normal, connectable integration. `kind` is the owning diff --git a/packages/react/src/lib/admin-users-display.ts b/packages/react/src/lib/admin-users-display.ts index 41ba53564..696f9bfe4 100644 --- a/packages/react/src/lib/admin-users-display.ts +++ b/packages/react/src/lib/admin-users-display.ts @@ -263,6 +263,38 @@ export const connectLinkUrl = ( return org ? `${base}/${org}/connect/${integration}` : `${base}/connect/${integration}`; }; +// ── Audit activity ───────────────────────────────────────────────────────── + +export interface AdminAuditActorRow { + readonly actorId: string | null; + readonly actorEmail: string | null; + readonly actorDisplayName: string | null; +} + +/** Human-readable actor, with the stable id retained as the final fallback. */ +export const adminAuditActorLabel = (event: AdminAuditActorRow): string => + event.actorEmail ?? event.actorDisplayName ?? event.actorId ?? "System"; + +export const adminAuditResourceLabel = (event: { + readonly resourceType: "connection" | "integration" | "oauth_client"; + readonly resourceParent: string | null; + readonly resourceId: string; +}): string => { + const kind = + event.resourceType === "oauth_client" + ? "OAuth app" + : event.resourceType === "integration" + ? "Integration" + : "Connection"; + const identifier = event.resourceParent + ? `${event.resourceParent} / ${event.resourceId}` + : event.resourceId; + return `${kind}: ${identifier}`; +}; + +export const adminAuditScopeLabel = (owner: Owner | null): string => + owner === "user" ? "Personal" : "Workspace"; + // ── Paging ────────────────────────────────────────────────────────────────── /** diff --git a/packages/react/src/multiplayer/use-admin-nav.tsx b/packages/react/src/multiplayer/use-admin-nav.tsx index c29dee29e..05c33436e 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/admin-users.tsx b/packages/react/src/pages/admin-users.tsx index 1fb2fdc40..39afa4b6d 100644 --- a/packages/react/src/pages/admin-users.tsx +++ b/packages/react/src/pages/admin-users.tsx @@ -9,7 +9,9 @@ import type { HealthStatus, Integration, IntegrationSlug } from "@executor-js/sd import { useIntegrationPlugins } from "@executor-js/sdk/client"; import { + ADMIN_AUDIT_EVENTS_PAGE_SIZE, ADMIN_USERS_PAGE_SIZE, + adminAuditEventsAtom, adminUserConnectionsAtom, adminUsersWithConnectionsAtom, } from "../api/admin-atoms"; @@ -18,6 +20,7 @@ import { ownerLabel } from "../api/owner-display"; import { Button } from "../components/button"; import { CopyButton } from "../components/copy-button"; import { ErrorState } from "../components/error-state"; +import { FilterTabs } from "../components/filter-tabs"; import { IntegrationFavicon, integrationInferredUrl, @@ -33,6 +36,9 @@ import { } from "../components/sheet"; import { Skeleton } from "../components/skeleton"; import { + adminAuditActorLabel, + adminAuditResourceLabel, + adminAuditScopeLabel, adminUserCopyableEmail, adminUserTitle, connectionHealthStatus, @@ -55,6 +61,7 @@ import { } from "../lib/health-display"; import { isAsyncResultLoading } from "../lib/async-result"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { formatRelativeTime } from "../lib/relative-time"; // --------------------------------------------------------------------------- // Admin · Users — the tenant-wide operator view. @@ -523,8 +530,142 @@ function UserDetail(props: { // ── Page ──────────────────────────────────────────────────────────────────── +type AdminAuditEventRow = { + readonly id: string; + readonly actorId: string | null; + readonly actorEmail: string | null; + readonly actorDisplayName: string | null; + readonly action: "created" | "updated" | "removed"; + readonly resourceType: "connection" | "integration" | "oauth_client"; + readonly resourceOwner: "org" | "user" | null; + readonly resourceParent: string | null; + readonly resourceId: string; + readonly createdAt: number; +}; + +const auditActionLabel = (action: AdminAuditEventRow["action"]): string => + `${action.slice(0, 1).toUpperCase()}${action.slice(1)}`; + +function AuditActivity() { + const [offset, setOffset] = useState(0); + const page = { limit: ADMIN_AUDIT_EVENTS_PAGE_SIZE, offset }; + const result = useAtomValue(adminAuditEventsAtom(page)); + const refresh = useAtomRefresh(adminAuditEventsAtom(page)); + const loading = ( +
+ {[0, 1, 2, 3].map((row) => ( + + ))} +
+ ); + + if (isAsyncResultLoading(result)) return loading; + return AsyncResult.match(result, { + onInitial: () => loading, + onFailure: (failure) => + isAccessDenied(failure.cause) ? ( + + ) : ( + + ), + onSuccess: ({ value }) => { + const { rows, hasNext } = splitPage(value.events, ADMIN_AUDIT_EVENTS_PAGE_SIZE); + if (rows.length === 0) { + return ( +
+

+ {offset === 0 ? "No activity yet" : "No activity on this page"} +

+

+ {offset === 0 + ? "Connection, integration, and OAuth app changes will appear here." + : "Go back a page to see earlier workspace activity."} +

+
+ ); + } + + return ( + <> +
+
+ When + Actor + Action + Resource + Scope +
+ {rows.map((event: AdminAuditEventRow) => ( +
+ + {formatRelativeTime(event.createdAt)} + + + {adminAuditActorLabel(event)} + + + {auditActionLabel(event.action)} + + + {adminAuditResourceLabel(event)} + + + {adminAuditScopeLabel(event.resourceOwner)} + +
+ ))} +
+ + {(hasNext || offset > 0) && ( +
+ + Page {pageNumber(offset, ADMIN_AUDIT_EVENTS_PAGE_SIZE)} + +
+ + +
+
+ )} + + ); + }, + }); +} + export function AdminUsersPage() { useExecutorDocumentTitle("Users"); + const [view, setView] = useState<"users" | "activity">("users"); const [offset, setOffset] = useState(0); const [selected, setSelected] = useState(null); @@ -559,115 +700,128 @@ export function AdminUsersPage() { {header} - {isAsyncResultLoading(result) - ? loading - : AsyncResult.match(result, { - onInitial: () => loading, - onFailure: (failure) => - isAccessDenied(failure.cause) ? ( - - ) : ( - - ), - onSuccess: ({ value }) => { - const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE); - - if (rows.length === 0) { - return ( -
-

- {offset === 0 ? "No users yet" : "No users on this page"} -

-

- {offset === 0 - ? "A user appears here the first time they reach this workspace or connect an account." - : "Go back a page to see this workspace's users."} -

- {offset > 0 && ( + + + {view === "activity" ? ( + + ) : isAsyncResultLoading(result) ? ( + loading + ) : ( + AsyncResult.match(result, { + onInitial: () => loading, + onFailure: (failure) => + isAccessDenied(failure.cause) ? ( + + ) : ( + + ), + onSuccess: ({ value }) => { + const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE); + + if (rows.length === 0) { + return ( +
+

+ {offset === 0 ? "No users yet" : "No users on this page"} +

+

+ {offset === 0 + ? "A user appears here the first time they reach this workspace or connect an account." + : "Go back a page to see this workspace's users."} +

+ {offset > 0 && ( + + )} +
+ ); + } + + return ( + <> +
+
+ User + Created + Last seen + Connections +
+ {rows.map((user: AdminUserRow) => ( + // oxlint-disable-next-line react/forbid-elements + + ))} +
+ + {(hasNext || offset > 0) && ( +
+ + Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)} + +
- )} -
- ); - } - - return ( - <> -
-
- User - Created - Last seen - Connections -
- {rows.map((user: AdminUserRow) => ( - // oxlint-disable-next-line react/forbid-elements - - ))} -
- - {(hasNext || offset > 0) && ( -
- - Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)} - -
- - -
+ Next +
- )} - - ); - }, - })} +
+ )} + + ); + }, + }) + )} !open && setSelected(null)}> diff --git a/packages/react/src/pages/integration-detail.tsx b/packages/react/src/pages/integration-detail.tsx index 0f2c0d1bd..522fea34d 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 0d1e8f960..ef9b0a8f5 100644 --- a/packages/react/src/plugins/connection-owner.test.ts +++ b/packages/react/src/plugins/connection-owner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { connectionOwnerOptions, + connectionOwnerOptionsForAccess, connectionOwnerOptionsForHost, defaultConnectionOwnerForHost, normalizeConnectionOwner, @@ -34,6 +35,26 @@ 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("normalizeConnectionOwner", () => { diff --git a/packages/react/src/plugins/connection-owner.tsx b/packages/react/src/plugins/connection-owner.tsx index 88853463e..9881f3a91 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,20 @@ 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"); +}; + export const defaultConnectionOwnerForHost = (organizationId: string | null): Owner => organizationId === null ? LOCAL_CONNECTION_OWNER : DEFAULT_CONNECTION_OWNER; @@ -107,7 +122,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), );