diff --git a/apps/cloud/drizzle/0016_nifty_tarot.sql b/apps/cloud/drizzle/0016_nifty_tarot.sql new file mode 100644 index 0000000000..db9a62b462 --- /dev/null +++ b/apps/cloud/drizzle/0016_nifty_tarot.sql @@ -0,0 +1,20 @@ +CREATE TABLE "access_group" ( + "id" varchar(255) NOT NULL, + "name" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "access_group_member" ( + "group_id" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL, + "created_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "connection" ADD COLUMN "access_group" varchar(255);--> statement-breakpoint +CREATE UNIQUE INDEX "access_group_uidx" ON "access_group" USING btree ("tenant","id");--> statement-breakpoint +CREATE UNIQUE INDEX "access_group_member_uidx" ON "access_group_member" USING btree ("tenant","group_id","subject"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0016_snapshot.json b/apps/cloud/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000000..1bb500fa58 --- /dev/null +++ b/apps/cloud/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1634 @@ +{ + "id": "b45187eb-7bd6-474b-800b-47cde18ae4ed", + "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.access_group": { + "name": "access_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "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 + } + }, + "indexes": { + "access_group_uidx": { + "name": "access_group_uidx", + "columns": [ + { + "expression": "tenant", + "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.access_group_member": { + "name": "access_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "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 + } + }, + "indexes": { + "access_group_member_uidx": { + "name": "access_group_member_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "access_group": { + "name": "access_group", + "type": "varchar(255)", + "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 fa90570831..1398e24dbb 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": 1786823746605, + "tag": "0016_nifty_tarot", + "breakpoints": true } ] } diff --git a/apps/cloud/src/access-groups/api.ts b/apps/cloud/src/access-groups/api.ts new file mode 100644 index 0000000000..49bc09c845 --- /dev/null +++ b/apps/cloud/src/access-groups/api.ts @@ -0,0 +1,212 @@ +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { WorkOSError } from "../auth/errors"; + +// --------------------------------------------------------------------------- +// Cloud access-groups API — ADMIN-ONLY management of connection access groups. +// +// Deliberately a cloud-local group behind the org-session auth middleware and +// a per-endpoint WorkOS admin-role gate, NOT part of the shared `ExecutorApi`: +// that surface's trust model is "any org member", and group management (who +// may see which org connection) must not ride it. Enforcement itself lives in +// the executor core — these endpoints only edit the group rows; a restricted +// connection is already invisible/uninvokable for non-members everywhere. +// --------------------------------------------------------------------------- + +export class AccessGroupsForbidden extends Schema.TaggedErrorClass()( + "AccessGroupsForbidden", + {}, + { httpApiStatus: 403 }, +) {} + +export class AccessGroupsNotFound extends Schema.TaggedErrorClass()( + "AccessGroupsNotFound", + {}, + { httpApiStatus: 404 }, +) {} + +/** Rule violations from the engine (empty name, group still referenced, + * unknown group) and storage failures, rendered with the engine's message — + * this is an admin plane, where the message IS the actionable content. */ +export class AccessGroupsError extends Schema.TaggedErrorClass()( + "AccessGroupsError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +export const AccessGroupItem = Schema.Struct({ + id: Schema.String, + name: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, +}); + +export const AccessGroupMemberItem = Schema.Struct({ + groupId: Schema.String, + /** The member's WorkOS account id (`user_...`) — the same principal id the + * subject table records. */ + subject: Schema.String, + createdAt: Schema.String, +}); + +export const AccessGroupRestrictionItem = Schema.Struct({ + integration: Schema.String, + name: Schema.String, + group: Schema.String, +}); + +export const ToolkitRestrictionItem = Schema.Struct({ + toolkitId: Schema.String, + slug: Schema.String, + group: Schema.String, +}); + +export const AccessGroupsResponse = Schema.Struct({ + groups: Schema.Array(AccessGroupItem), +}); + +export const AccessGroupMembersResponse = Schema.Struct({ + members: Schema.Array(AccessGroupMemberItem), +}); + +export const AccessGroupRestrictionsResponse = Schema.Struct({ + restrictions: Schema.Array(AccessGroupRestrictionItem), +}); + +export const ToolkitRestrictionsResponse = Schema.Struct({ + restrictions: Schema.Array(ToolkitRestrictionItem), +}); + +export const SuccessResponse = Schema.Struct({ + success: Schema.Boolean, +}); + +export const GroupNameBody = Schema.Struct({ + name: Schema.String, +}); + +export const AddMemberBody = Schema.Struct({ + subject: Schema.String, +}); + +export const RestrictConnectionBody = Schema.Struct({ + integration: Schema.String, + name: Schema.String, + group: Schema.String, +}); + +export const RestrictToolkitBody = Schema.Struct({ + toolkitId: Schema.String, + group: Schema.String, +}); + +const GroupParams = { groupId: Schema.String }; +const MemberParams = { groupId: Schema.String, subject: Schema.String }; +const RestrictionParams = { integration: Schema.String, name: Schema.String }; +const ToolkitParams = { toolkitId: Schema.String }; + +const ERRORS = [WorkOSError, AccessGroupsForbidden, AccessGroupsNotFound, AccessGroupsError]; + +export class AccessGroupsApi extends HttpApiGroup.make("accessGroups") + .add( + HttpApiEndpoint.get("listGroups", "/org/access-groups", { + success: AccessGroupsResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("createGroup", "/org/access-groups", { + payload: GroupNameBody, + success: AccessGroupItem, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("renameGroup", "/org/access-groups/:groupId", { + params: GroupParams, + payload: GroupNameBody, + success: AccessGroupItem, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.delete("deleteGroup", "/org/access-groups/:groupId", { + params: GroupParams, + success: SuccessResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.get("listMembers", "/org/access-groups/:groupId/members", { + params: GroupParams, + success: AccessGroupMembersResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("addMember", "/org/access-groups/:groupId/members", { + params: GroupParams, + payload: AddMemberBody, + success: AccessGroupMemberItem, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.delete("removeMember", "/org/access-groups/:groupId/members/:subject", { + params: MemberParams, + success: SuccessResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.get("listRestrictions", "/org/access-group-restrictions", { + success: AccessGroupRestrictionsResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("restrictConnection", "/org/access-group-restrictions", { + payload: RestrictConnectionBody, + success: SuccessResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.delete( + "unrestrictConnection", + "/org/access-group-restrictions/:integration/:name", + { + params: RestrictionParams, + success: SuccessResponse, + error: ERRORS, + }, + ), + ) + .add( + HttpApiEndpoint.get("listToolkitRestrictions", "/org/access-group-toolkit-restrictions", { + success: ToolkitRestrictionsResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("restrictToolkit", "/org/access-group-toolkit-restrictions", { + payload: RestrictToolkitBody, + success: SuccessResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.delete( + "unrestrictToolkit", + "/org/access-group-toolkit-restrictions/:toolkitId", + { + params: ToolkitParams, + success: SuccessResponse, + error: ERRORS, + }, + ), + ) {} + +/** Access-groups API with org-level auth supplied by the router middleware in + * ../org/auth-middleware.ts (same mounting as the org domains group). */ +export const AccessGroupsHttpApi = HttpApi.make("accessGroups").add(AccessGroupsApi); diff --git a/apps/cloud/src/access-groups/handlers.test.ts b/apps/cloud/src/access-groups/handlers.test.ts new file mode 100644 index 0000000000..99f2848dcb --- /dev/null +++ b/apps/cloud/src/access-groups/handlers.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "@effect/vitest"; +import { Data, Effect, Layer, Predicate } from "effect"; + +import { AuthContext } from "@executor-js/api/server"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { AccessGroupsForbidden } from "./api"; + +// --------------------------------------------------------------------------- +// Access-groups handler guard. Every endpoint in the group runs behind the +// same WorkOS admin-role gate as the org domains plane; these tests pin that +// guard (mirroring `access-groups/handlers.ts`, the same convention as +// `org/handlers.test.ts`): a plain member, a platform credential (null +// accountId), and a missing membership are all refused. The full HTTP-level +// gate is exercised end-to-end on the self-host plane +// (`apps/host-selfhost/src/admin/access-groups.node.test.ts`), which shares +// the engine; enforcement semantics live in the sdk's +// access-group-enforcement.test.ts. +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test stub needs wide function types +type StubFn = (...args: never[]) => Effect.Effect; + +type StubOverrides = { + getUserOrgMembership?: StubFn; +}; + +class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{ + method: string; +}> {} + +const stubWorkOS = (overrides: StubOverrides = {}) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + if (typeof prop === "string" && prop in overrides) { + return overrides[prop as keyof StubOverrides]; + } + return () => + Effect.fail( + new UnstubbedWorkOSMethod({ + method: typeof prop === "string" ? prop : (prop.description ?? "symbol"), + }), + ); + }, + }), + ); + +const adminAuth = { + accountId: "user_admin", + organizationId: "org_1", + email: "admin@test.com", + name: "Admin", + avatarUrl: null, + roles: [], +}; + +const memberAuth = { ...adminAuth, accountId: "user_member" }; +const platformAuth = { ...adminAuth, accountId: null }; + +const provide = (auth: typeof adminAuth | typeof platformAuth, overrides: StubOverrides = {}) => + Layer.mergeAll(Layer.succeed(AuthContext)(auth), stubWorkOS(overrides)); + +// Mirrors `access-groups/handlers.ts` `requireAdmin`. +const requireAdmin = Effect.gen(function* () { + const auth = yield* AuthContext; + if (auth.accountId === null) return yield* new AccessGroupsForbidden(); + const workos = yield* WorkOSClient; + const membership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); + if (!membership || membership.role?.slug !== "admin") { + return yield* new AccessGroupsForbidden(); + } + return { accountId: auth.accountId, organizationId: auth.organizationId }; +}); + +const withMemberships: StubOverrides = { + getUserOrgMembership: (_organizationId: string, userId: string) => + Effect.succeed( + userId === "user_admin" + ? { id: "mem_admin", userId, status: "active", role: { slug: "admin" } } + : { id: "mem_member", userId, status: "active", role: { slug: "member" } }, + ), +}; + +describe("access-groups requireAdmin", () => { + it.effect("passes for an admin caller and returns the binding", () => + Effect.gen(function* () { + const binding = yield* requireAdmin; + expect(binding).toEqual({ accountId: "user_admin", organizationId: "org_1" }); + }).pipe(Effect.provide(provide(adminAuth, withMemberships))), + ); + + it.effect("rejects a plain member with Forbidden", () => + Effect.gen(function* () { + const error = yield* Effect.flip(requireAdmin); + expect(Predicate.isTagged("AccessGroupsForbidden")(error)).toBe(true); + }).pipe(Effect.provide(provide(memberAuth, withMemberships))), + ); + + it.effect("rejects the platform credential (no acting member) with Forbidden", () => + Effect.gen(function* () { + const error = yield* Effect.flip(requireAdmin); + expect(Predicate.isTagged("AccessGroupsForbidden")(error)).toBe(true); + }).pipe(Effect.provide(provide(platformAuth))), + ); + + it.effect("rejects a caller with no membership at all", () => + Effect.gen(function* () { + const error = yield* Effect.flip(requireAdmin); + expect(Predicate.isTagged("AccessGroupsForbidden")(error)).toBe(true); + }).pipe( + Effect.provide(provide(memberAuth, { getUserOrgMembership: () => Effect.succeed(null) })), + ), + ); +}); diff --git a/apps/cloud/src/access-groups/handlers.ts b/apps/cloud/src/access-groups/handlers.ts new file mode 100644 index 0000000000..508952194e --- /dev/null +++ b/apps/cloud/src/access-groups/handlers.ts @@ -0,0 +1,291 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; + +import { AuthContext, makeScopedExecutor } from "@executor-js/api/server"; +import { + ConnectionName, + IntegrationSlug, + type ConnectionNotFoundError, + type Executor, + type StorageFailure, +} from "@executor-js/sdk"; + +import { WorkOSClient } from "../auth/workos"; +import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; +import type { CloudPlugins } from "../plugins"; +import { + AccessGroupsError, + AccessGroupsForbidden, + AccessGroupsHttpApi, + AccessGroupsNotFound, +} from "./api"; + +// --------------------------------------------------------------------------- +// Cloud access-groups handlers. Every route is gated by the SAME WorkOS +// admin-role check the org domains plane uses, then runs against a scoped +// executor bound to the ADMIN caller — writable (unlike the platform view) +// and per-request (it holds the request's postgres socket, which Cloudflare's +// I/O isolation forbids sharing). The engine's `accessGroups` closures read +// the tenant-scoped tables tenant-wide, so an admin manages groups they are +// not a member of; their RUNTIME sessions stay filtered like everyone's. +// --------------------------------------------------------------------------- + +const requireAdmin = Effect.gen(function* () { + const auth = yield* AuthContext; + // Mounted behind the session-only org auth middleware, so the caller is + // always a member — but `accountId` is nullable for the platform + // credential, and membership of "no member" is not a question worth asking + // WorkOS. Refuse rather than assert. + if (auth.accountId === null) return yield* new AccessGroupsForbidden(); + const workos = yield* WorkOSClient; + const membership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); + if (!membership || membership.role?.slug !== "admin") { + return yield* new AccessGroupsForbidden(); + } + return { accountId: auth.accountId, organizationId: auth.organizationId }; +}); + +/** Render engine failures in this plane's vocabulary: a missing connection is + * 404, everything else (rule violations, storage) carries its message. */ +const renderEngineErrors = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchTag("ConnectionNotFoundError", () => Effect.fail(new AccessGroupsNotFound())), + Effect.catchTag("StorageError", (error) => + Effect.fail(new AccessGroupsError({ message: error.message })), + ), + Effect.catchTag("UniqueViolationError", () => + Effect.fail(new AccessGroupsError({ message: "Storage conflict" })), + ), + ); + +/** Render toolkit-extension failures: the extension's rule violations + * (unknown toolkit, non-org toolkit) all surface as `ToolkitError` with an + * actionable message — this is an admin plane, so the message rides through + * as a 400. */ +const renderToolkitErrors = ( + effect: Effect.Effect< + A, + { readonly _tag: "ToolkitError"; readonly message: string } | StorageFailure, + R + >, +): Effect.Effect => + effect.pipe( + Effect.catchTag("ToolkitError", (error) => + Effect.fail(new AccessGroupsError({ message: error.message })), + ), + Effect.catchTag("StorageError", (error) => + Effect.fail(new AccessGroupsError({ message: error.message })), + ), + Effect.catchTag("UniqueViolationError", () => + Effect.fail(new AccessGroupsError({ message: "Storage conflict" })), + ), + ); + +/** Authorize, then run `body` against a writable executor bound to the admin + * caller; the executor is opened per request and always closed. Typed with + * the host plugin tuple so the toolkits extension is reachable. */ +const withAdminExecutor = (body: (executor: Executor) => Effect.Effect) => + Effect.gen(function* () { + const { accountId, organizationId } = yield* requireAdmin; + const executor = yield* makeScopedExecutor(accountId, organizationId, "").pipe( + Effect.mapError(() => new AccessGroupsError({ message: "Failed to open the executor" })), + ); + return yield* Effect.ensuring(body(executor), executor.close().pipe(Effect.ignore)); + }).pipe(Effect.provide(CloudExecutionSeamsLayer)); + +/** The toolkit grant names a group that must exist — this plane owns that + * referential check (the toolkits plugin cannot read the group tables). */ +const requireGroupExists = (executor: Executor, group: string) => + executor.accessGroups.list().pipe( + Effect.catchTag("StorageError", (error) => + Effect.fail(new AccessGroupsError({ message: error.message })), + ), + Effect.catchTag("UniqueViolationError", () => + Effect.fail(new AccessGroupsError({ message: "Storage conflict" })), + ), + Effect.flatMap((groups) => + groups.some((candidate) => String(candidate.id) === group) + ? Effect.void + : Effect.fail(new AccessGroupsError({ message: `Access group not found: ${group}` })), + ), + ); + +const groupToWire = (group: { + readonly id: string; + readonly name: string; + readonly createdAt: Date; + readonly updatedAt: Date; +}) => ({ + id: group.id, + name: group.name, + createdAt: group.createdAt.toISOString(), + updatedAt: group.updatedAt.toISOString(), +}); + +const memberToWire = (member: { + readonly groupId: string; + readonly subject: string; + readonly createdAt: Date; +}) => ({ + groupId: member.groupId, + subject: member.subject, + createdAt: member.createdAt.toISOString(), +}); + +export const AccessGroupsHandlers = HttpApiBuilder.group( + AccessGroupsHttpApi, + "accessGroups", + (handlers) => + handlers + .handle("listGroups", () => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups.list().pipe( + Effect.map((groups) => ({ + groups: groups.map(groupToWire), + })), + ), + ), + ), + ) + .handle("createGroup", ({ payload }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups.create({ name: payload.name }).pipe(Effect.map(groupToWire)), + ), + ), + ) + .handle("renameGroup", ({ params, payload }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .update({ id: params.groupId, name: payload.name }) + .pipe(Effect.map(groupToWire)), + ), + ), + ) + .handle("deleteGroup", ({ params }) => + withAdminExecutor((executor) => + // The engine refuses deletion while a CONNECTION references the + // group; toolkit grants live in plugin storage the engine cannot + // see, so this plane holds the same no-dangling-reference line for + // them (a dangling grant would hide the toolkit from everyone). + renderToolkitErrors(executor.toolkits.listRestrictedToolkits()).pipe( + Effect.flatMap((grants) => { + const grant = grants.find((candidate) => candidate.group === params.groupId); + return grant + ? Effect.fail( + new AccessGroupsError({ + message: `Access group ${params.groupId} still restricts toolkit ${grant.slug}; remove that grant before deleting the group.`, + }), + ) + : renderEngineErrors( + executor.accessGroups + .remove({ id: params.groupId }) + .pipe(Effect.map(() => ({ success: true }))), + ); + }), + ), + ), + ) + .handle("listMembers", ({ params }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .members(params.groupId) + .pipe(Effect.map((members) => ({ members: members.map(memberToWire) }))), + ), + ), + ) + .handle("addMember", ({ params, payload }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .addMember({ id: params.groupId, subject: payload.subject }) + .pipe(Effect.map(memberToWire)), + ), + ), + ) + .handle("removeMember", ({ params }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .removeMember({ id: params.groupId, subject: params.subject }) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ) + .handle("listRestrictions", () => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups.restrictions().pipe( + Effect.map((restrictions) => ({ + restrictions: restrictions.map((restriction) => ({ + integration: String(restriction.integration), + name: String(restriction.name), + group: String(restriction.group), + })), + })), + ), + ), + ), + ) + .handle("restrictConnection", ({ payload }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .restrictConnection({ + integration: IntegrationSlug.make(payload.integration), + name: ConnectionName.make(payload.name), + group: payload.group, + }) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ) + .handle("unrestrictConnection", ({ params }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .unrestrictConnection({ + integration: IntegrationSlug.make(params.integration), + name: ConnectionName.make(params.name), + }) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ) + .handle("listToolkitRestrictions", () => + withAdminExecutor((executor) => + renderToolkitErrors( + executor.toolkits + .listRestrictedToolkits() + .pipe(Effect.map((restrictions) => ({ restrictions }))), + ), + ), + ) + .handle("restrictToolkit", ({ payload }) => + withAdminExecutor((executor) => + requireGroupExists(executor, payload.group).pipe( + Effect.andThen( + renderToolkitErrors( + executor.toolkits + .setAccessGroup(payload.toolkitId, payload.group) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ), + ), + ) + .handle("unrestrictToolkit", ({ params }) => + withAdminExecutor((executor) => + renderToolkitErrors( + executor.toolkits + .setAccessGroup(params.toolkitId, null) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ), +); diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index 6e73261264..bdad8c71f1 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -16,6 +16,8 @@ import { WorkerTelemetryLive } from "../observability/telemetry"; import { OrgHttpApi } from "../org/api"; import { orgAuthMiddleware } from "../org/auth-middleware"; import { OrgHandlers } from "../org/handlers"; +import { AccessGroupsHttpApi } from "../access-groups/api"; +import { AccessGroupsHandlers } from "../access-groups/handlers"; import { ErrorCaptureLive } from "../observability"; import { AutumnService } from "../extensions/billing/service"; @@ -75,6 +77,15 @@ export const makeOrgApiLive = (rsLive: Layer.Layer Layer.provideMerge(AutumnService.Default), ); +// Admin-only access-group management. Same org-session auth middleware as the +// domains plane; the WorkOS admin-role gate and the per-request scoped +// executor (built over the request's DbService) live in the handlers. +export const makeAccessGroupsApiLive = (rsLive: Layer.Layer) => + HttpApiBuilder.layer(AccessGroupsHttpApi).pipe( + Layer.provide(AccessGroupsHandlers), + Layer.provide(orgAuthMiddleware(rsLive)), + ); + // Default export uses the production per-request layer. Existing callers that // import `NonProtectedApiLive` continue to work; the `make*` factory exists for // tests that need to swap in a fake. diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts index d74f9c0b25..002998c456 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -13,6 +13,7 @@ import { ApiErrorLoggingLive } from "../observability/error-logging"; import { BootSharedServices, RequestScopedServicesLive, + makeAccessGroupsApiLive, makeOrgApiLive, makeNonProtectedApiLive, } from "./layers"; @@ -36,6 +37,7 @@ export const makeApiLive = (requestScopedLive: Layer.Layer [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)], ); +export const access_group = pgTable( + "access_group", + { + id: varchar("id", { length: 255 }).notNull(), + name: text("name").notNull(), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("access_group_uidx").on(table.tenant, table.id)], +); + +export const access_group_member = pgTable( + "access_group_member", + { + group_id: varchar("group_id", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + created_at: timestamp("created_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("access_group_member_uidx").on(table.tenant, table.group_id, table.subject), + ], +); + export const connection = pgTable( "connection", { @@ -68,6 +101,7 @@ export const connection = pgTable( oauth_scope: text("oauth_scope"), oauth_token_url: text("oauth_token_url"), provider_state: json("provider_state"), + access_group: varchar("access_group", { length: 255 }), created_at: timestamp("created_at").notNull(), updated_at: timestamp("updated_at").notNull(), row_id: varchar("row_id", { length: 255 }) diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index 86faa45bb9..833e32b705 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -28,6 +28,8 @@ import * as cloudSchema from "./schema"; import * as executorSchema from "./executor-schema"; import { memberships, accounts } from "./schema"; import { + access_group, + access_group_member, artifact, blob, connection, @@ -158,6 +160,20 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { subject: "s", }); + await db.insert(access_group).values({ + id: `grp-${tag}`, + name: "finance", + created_at: now, + updated_at: now, + tenant, + }); + await db.insert(access_group_member).values({ + group_id: `grp-${tag}`, + subject: "s", + created_at: now, + tenant, + }); + const orgNs = `o:${tenant}/plugin`; const userNs = `u:${tenant}:subject/plugin`; await db.insert(blob).values({ @@ -185,6 +201,8 @@ const TENANT_TABLES = [ plugin_storage, subject, artifact, + access_group, + access_group_member, ] as const; // Tables that are NOT purged by org id, each with the reason it is exempt. Any diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index b2a922a3fd..edc654cc14 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -16,6 +16,8 @@ import { eq, or, sql } from "drizzle-orm"; import type { DrizzleDb } from "./db"; import { organizations } from "./schema"; import { + access_group, + access_group_member, artifact, blob, connection, @@ -52,6 +54,8 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); await tx.delete(subject).where(eq(subject.tenant, organizationId)); await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); + await tx.delete(access_group_member).where(eq(access_group_member.tenant, organizationId)); + await tx.delete(access_group).where(eq(access_group.tenant, organizationId)); // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by // owner: `o:/` (org scope) and `u::/` diff --git a/apps/host-selfhost/src/admin/access-groups-api.ts b/apps/host-selfhost/src/admin/access-groups-api.ts new file mode 100644 index 0000000000..3a61181d64 --- /dev/null +++ b/apps/host-selfhost/src/admin/access-groups-api.ts @@ -0,0 +1,226 @@ +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; + +// --------------------------------------------------------------------------- +// Self-host access-groups API — ADMIN-ONLY management of connection access +// groups, mounted beside the invite-code admin routes under /api/admin/*. +// +// Every route is gated by the shared `requireInstanceAdmin` (require-admin.ts), +// which authorizes against the INSTANCE's own organization — the same +// escalation defense the other admin planes use. Deliberately NOT part of the +// shared `ExecutorApi` (whose trust model is "any org member"). Enforcement +// lives in the executor core; these endpoints only edit the group rows. +// +// Browser-safe: schemas + the HttpApi value only (no server imports), so the +// web client can build a typed AtomHttpApi from it. +// --------------------------------------------------------------------------- + +export class AccessGroupsError extends Schema.TaggedErrorClass()( + "AccessGroupsError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +export class AccessGroupsUnauthorized extends Schema.TaggedErrorClass()( + "AccessGroupsUnauthorized", + {}, + { httpApiStatus: 401 }, +) {} + +export class AccessGroupsForbidden extends Schema.TaggedErrorClass()( + "AccessGroupsForbidden", + {}, + { httpApiStatus: 403 }, +) {} + +export class AccessGroupsNotFound extends Schema.TaggedErrorClass()( + "AccessGroupsNotFound", + {}, + { httpApiStatus: 404 }, +) {} + +export const AccessGroupItem = Schema.Struct({ + id: Schema.String, + name: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, +}); + +export const AccessGroupMemberItem = Schema.Struct({ + groupId: Schema.String, + /** The member's Better Auth `user.id` — the same principal id the subject + * table records. */ + subject: Schema.String, + createdAt: Schema.String, +}); + +export const AccessGroupRestrictionItem = Schema.Struct({ + integration: Schema.String, + name: Schema.String, + group: Schema.String, +}); + +export const ToolkitRestrictionItem = Schema.Struct({ + toolkitId: Schema.String, + slug: Schema.String, + group: Schema.String, +}); + +export const AccessGroupsResponse = Schema.Struct({ + groups: Schema.Array(AccessGroupItem), +}); + +export const AccessGroupMembersResponse = Schema.Struct({ + members: Schema.Array(AccessGroupMemberItem), +}); + +export const AccessGroupRestrictionsResponse = Schema.Struct({ + restrictions: Schema.Array(AccessGroupRestrictionItem), +}); + +export const ToolkitRestrictionsResponse = Schema.Struct({ + restrictions: Schema.Array(ToolkitRestrictionItem), +}); + +export const SuccessResponse = Schema.Struct({ + success: Schema.Boolean, +}); + +export const GroupNameBody = Schema.Struct({ + name: Schema.String, +}); + +export const AddMemberBody = Schema.Struct({ + subject: Schema.String, +}); + +export const RestrictConnectionBody = Schema.Struct({ + integration: Schema.String, + name: Schema.String, + group: Schema.String, +}); + +export const RestrictToolkitBody = Schema.Struct({ + toolkitId: Schema.String, + group: Schema.String, +}); + +const GroupParams = { groupId: Schema.String }; +const MemberParams = { groupId: Schema.String, subject: Schema.String }; +const RestrictionParams = { integration: Schema.String, name: Schema.String }; +const ToolkitParams = { toolkitId: Schema.String }; + +const ERRORS = [ + AccessGroupsError, + AccessGroupsUnauthorized, + AccessGroupsForbidden, + AccessGroupsNotFound, +]; + +// Paths are `/admin/*` (no `/api`): the server mounts this on the same +// `/api`-prefixed router as the core API — symmetric with the invite plane. +export const AccessGroupsApi = HttpApiGroup.make("accessGroups") + .add( + HttpApiEndpoint.get("listGroups", "/admin/access-groups", { + success: AccessGroupsResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("createGroup", "/admin/access-groups", { + payload: GroupNameBody, + success: AccessGroupItem, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("renameGroup", "/admin/access-groups/:groupId", { + params: GroupParams, + payload: GroupNameBody, + success: AccessGroupItem, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.delete("deleteGroup", "/admin/access-groups/:groupId", { + params: GroupParams, + success: SuccessResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.get("listMembers", "/admin/access-groups/:groupId/members", { + params: GroupParams, + success: AccessGroupMembersResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("addMember", "/admin/access-groups/:groupId/members", { + params: GroupParams, + payload: AddMemberBody, + success: AccessGroupMemberItem, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.delete("removeMember", "/admin/access-groups/:groupId/members/:subject", { + params: MemberParams, + success: SuccessResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.get("listRestrictions", "/admin/access-group-restrictions", { + success: AccessGroupRestrictionsResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("restrictConnection", "/admin/access-group-restrictions", { + payload: RestrictConnectionBody, + success: SuccessResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.delete( + "unrestrictConnection", + "/admin/access-group-restrictions/:integration/:name", + { + params: RestrictionParams, + success: SuccessResponse, + error: ERRORS, + }, + ), + ) + .add( + HttpApiEndpoint.get("listToolkitRestrictions", "/admin/access-group-toolkit-restrictions", { + success: ToolkitRestrictionsResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.post("restrictToolkit", "/admin/access-group-toolkit-restrictions", { + payload: RestrictToolkitBody, + success: SuccessResponse, + error: ERRORS, + }), + ) + .add( + HttpApiEndpoint.delete( + "unrestrictToolkit", + "/admin/access-group-toolkit-restrictions/:toolkitId", + { + params: ToolkitParams, + success: SuccessResponse, + error: ERRORS, + }, + ), + ); + +/** Standalone HttpApi wrapping the access-groups group — mounted server-side + * as an extension route layer, consumable by a typed web client. */ +export const AccessGroupsHttpApi = HttpApi.make("executor-self-host-access-groups").add( + AccessGroupsApi, +); diff --git a/apps/host-selfhost/src/admin/access-groups-handlers.ts b/apps/host-selfhost/src/admin/access-groups-handlers.ts new file mode 100644 index 0000000000..de3f63c14a --- /dev/null +++ b/apps/host-selfhost/src/admin/access-groups-handlers.ts @@ -0,0 +1,332 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; + +import { makeScopedExecutor } from "@executor-js/api/server"; +import { + ConnectionName, + IntegrationSlug, + type ConnectionNotFoundError, + type Executor, + type StorageFailure, +} from "@executor-js/sdk"; + +import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; +import { requireInstanceAdmin } from "./require-admin"; +import { SelfHostDb, SelfHostDbProvider, type SelfHostDbHandle } from "../db/self-host-db"; +import { SelfHostHostConfig, SelfHostPluginsProvider } from "../execution"; +import type { SelfHostPlugins } from "../plugins"; +import { + AccessGroupsError, + AccessGroupsForbidden, + AccessGroupsHttpApi, + AccessGroupsNotFound, + AccessGroupsUnauthorized, +} from "./access-groups-api"; + +// --------------------------------------------------------------------------- +// Handlers for the self-host access-groups API. Every route is gated by the +// SHARED `requireInstanceAdmin` (the same gate as the invite plane, resolved +// against the INSTANCE's own organization — see require-admin.ts), then runs +// against a scoped executor bound to the ADMIN caller: writable, unlike the +// admin-users platform view, because this plane edits group rows. The +// engine's `accessGroups` closures read the tenant-scoped tables tenant-wide, +// so an admin manages groups they are not a member of; their RUNTIME sessions +// stay filtered like everyone's. +// --------------------------------------------------------------------------- + +const requestHeaders = Effect.map( + HttpServerRequest.HttpServerRequest.asEffect(), + (request): Headers => new Headers({ ...request.headers }), +); + +const requireAdmin = (headers: Headers) => + requireInstanceAdmin(headers).pipe( + Effect.mapError((denial) => + denial === "unauthorized" ? new AccessGroupsUnauthorized() : new AccessGroupsForbidden(), + ), + ); + +/** Render engine failures in this plane's vocabulary: a missing connection is + * 404, everything else (rule violations, storage) carries its message. */ +const renderEngineErrors = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchTag("ConnectionNotFoundError", () => Effect.fail(new AccessGroupsNotFound())), + Effect.catchTag("StorageError", (error) => + Effect.fail(new AccessGroupsError({ message: error.message })), + ), + Effect.catchTag("UniqueViolationError", () => + Effect.fail(new AccessGroupsError({ message: "Storage conflict" })), + ), + ); + +/** Render toolkit-extension failures: the extension's rule violations + * (unknown toolkit, non-org toolkit) all surface as `ToolkitError` with an + * actionable message — this is an admin plane, so the message rides through + * as a 400. */ +const renderToolkitErrors = ( + effect: Effect.Effect< + A, + { readonly _tag: "ToolkitError"; readonly message: string } | StorageFailure, + R + >, +): Effect.Effect => + effect.pipe( + Effect.catchTag("ToolkitError", (error) => + Effect.fail(new AccessGroupsError({ message: error.message })), + ), + Effect.catchTag("StorageError", (error) => + Effect.fail(new AccessGroupsError({ message: error.message })), + ), + Effect.catchTag("UniqueViolationError", () => + Effect.fail(new AccessGroupsError({ message: "Storage conflict" })), + ), + ); + +/** Authorize, then run `body` against a writable executor bound to the admin + * caller; the executor is always closed. Typed with the host plugin tuple so + * the toolkits extension is reachable. */ +const withAdminExecutor = ( + body: (executor: Executor) => Effect.Effect, +) => + Effect.gen(function* () { + const member = yield* requireAdmin(yield* requestHeaders); + const { organizationId } = yield* BetterAuth; + const executor = yield* makeScopedExecutor( + member.userId, + organizationId, + "", + ).pipe( + Effect.mapError(() => new AccessGroupsError({ message: "Failed to open the executor" })), + ); + return yield* Effect.ensuring(body(executor), executor.close().pipe(Effect.ignore)); + }); + +/** The toolkit grant names a group that must exist — this plane owns that + * referential check (the toolkits plugin cannot read the group tables). */ +const requireGroupExists = (executor: Executor, group: string) => + executor.accessGroups.list().pipe( + Effect.catchTag("StorageError", (error) => + Effect.fail(new AccessGroupsError({ message: error.message })), + ), + Effect.catchTag("UniqueViolationError", () => + Effect.fail(new AccessGroupsError({ message: "Storage conflict" })), + ), + Effect.flatMap((groups) => + groups.some((candidate) => String(candidate.id) === group) + ? Effect.void + : Effect.fail(new AccessGroupsError({ message: `Access group not found: ${group}` })), + ), + ); + +const groupToWire = (group: { + readonly id: string; + readonly name: string; + readonly createdAt: Date; + readonly updatedAt: Date; +}) => ({ + id: group.id, + name: group.name, + createdAt: group.createdAt.toISOString(), + updatedAt: group.updatedAt.toISOString(), +}); + +const memberToWire = (member: { + readonly groupId: string; + readonly subject: string; + readonly createdAt: Date; +}) => ({ + groupId: member.groupId, + subject: member.subject, + createdAt: member.createdAt.toISOString(), +}); + +export const AccessGroupsHandlers = HttpApiBuilder.group( + AccessGroupsHttpApi, + "accessGroups", + (handlers) => + handlers + .handle("listGroups", () => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .list() + .pipe(Effect.map((groups) => ({ groups: groups.map(groupToWire) }))), + ), + ), + ) + .handle("createGroup", ({ payload }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups.create({ name: payload.name }).pipe(Effect.map(groupToWire)), + ), + ), + ) + .handle("renameGroup", ({ params, payload }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .update({ id: params.groupId, name: payload.name }) + .pipe(Effect.map(groupToWire)), + ), + ), + ) + .handle("deleteGroup", ({ params }) => + withAdminExecutor((executor) => + // The engine refuses deletion while a CONNECTION references the + // group; toolkit grants live in plugin storage the engine cannot + // see, so this plane holds the same no-dangling-reference line for + // them (a dangling grant would hide the toolkit from everyone). + renderToolkitErrors(executor.toolkits.listRestrictedToolkits()).pipe( + Effect.flatMap((grants) => { + const grant = grants.find((candidate) => candidate.group === params.groupId); + return grant + ? Effect.fail( + new AccessGroupsError({ + message: `Access group ${params.groupId} still restricts toolkit ${grant.slug}; remove that grant before deleting the group.`, + }), + ) + : renderEngineErrors( + executor.accessGroups + .remove({ id: params.groupId }) + .pipe(Effect.map(() => ({ success: true }))), + ); + }), + ), + ), + ) + .handle("listMembers", ({ params }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .members(params.groupId) + .pipe(Effect.map((members) => ({ members: members.map(memberToWire) }))), + ), + ), + ) + .handle("addMember", ({ params, payload }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .addMember({ id: params.groupId, subject: payload.subject }) + .pipe(Effect.map(memberToWire)), + ), + ), + ) + .handle("removeMember", ({ params }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .removeMember({ id: params.groupId, subject: params.subject }) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ) + .handle("listRestrictions", () => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups.restrictions().pipe( + Effect.map((restrictions) => ({ + restrictions: restrictions.map((restriction) => ({ + integration: String(restriction.integration), + name: String(restriction.name), + group: String(restriction.group), + })), + })), + ), + ), + ), + ) + .handle("restrictConnection", ({ payload }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .restrictConnection({ + integration: IntegrationSlug.make(payload.integration), + name: ConnectionName.make(payload.name), + group: payload.group, + }) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ) + .handle("unrestrictConnection", ({ params }) => + withAdminExecutor((executor) => + renderEngineErrors( + executor.accessGroups + .unrestrictConnection({ + integration: IntegrationSlug.make(params.integration), + name: ConnectionName.make(params.name), + }) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ) + .handle("listToolkitRestrictions", () => + withAdminExecutor((executor) => + renderToolkitErrors( + executor.toolkits + .listRestrictedToolkits() + .pipe(Effect.map((restrictions) => ({ restrictions }))), + ), + ), + ) + .handle("restrictToolkit", ({ payload }) => + withAdminExecutor((executor) => + requireGroupExists(executor, payload.group).pipe( + Effect.andThen( + renderToolkitErrors( + executor.toolkits + .setAccessGroup(payload.toolkitId, payload.group) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ), + ), + ) + .handle("unrestrictToolkit", ({ params }) => + withAdminExecutor((executor) => + renderToolkitErrors( + executor.toolkits + .setAccessGroup(params.toolkitId, null) + .pipe(Effect.map(() => ({ success: true }))), + ), + ), + ), +); + +export interface SelfHostAccessGroupsApiDeps { + readonly betterAuth: BetterAuthHandle; + readonly db: SelfHostDbHandle; + readonly mountPrefix: `/${string}`; +} + +/** + * The mountable extension route layer: registers the access-groups routes on + * the `mountPrefix`-prefixed view of the ambient router (so `/admin/*` is + * served at `/api/admin/*`). Better Auth, the DB handle, and the execution + * seams the scoped executor needs are app singletons, provided via + * `provideRequest` so the handlers' per-request requirement markers are + * cleared — same shape as the invite plane's layer factory. + */ +export const makeSelfHostAccessGroupsApiLayer = ({ + betterAuth, + db, + mountPrefix, +}: SelfHostAccessGroupsApiDeps) => { + const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), + ); + const seams = Layer.mergeAll( + SelfHostDbProvider, + SelfHostPluginsProvider, + SelfHostHostConfig, + ).pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))); + return HttpApiBuilder.layer(AccessGroupsHttpApi).pipe( + Layer.provide(AccessGroupsHandlers), + Layer.provide(prefixedRouter), + HttpRouter.provideRequest(Layer.mergeAll(Layer.succeed(BetterAuth)(betterAuth), seams)), + ); +}; diff --git a/apps/host-selfhost/src/admin/access-groups.node.test.ts b/apps/host-selfhost/src/admin/access-groups.node.test.ts new file mode 100644 index 0000000000..47e0125a9d --- /dev/null +++ b/apps/host-selfhost/src/admin/access-groups.node.test.ts @@ -0,0 +1,215 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// The self-host access-groups plane (`/api/admin/access-groups*`), over the +// REAL booted app. What this pins: the mount is live, the shared owner/admin +// gate refuses a plain member (403) and an anonymous caller (401), and the +// admin can run the group CRUD + membership round trip. Enforcement semantics +// (who then sees what) are covered by the sdk's +// access-group-enforcement.test.ts — this is the HTTP gate. + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-access-groups-")); +process.env.BETTER_AUTH_SECRET = "access-groups-test-secret-0123456789-abcdef"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@access-groups.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; + +const { makeSelfHostApiHandler } = await import("../app"); +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +const signIn = async (email: string, password: string): Promise => { + const response = await handler( + new Request(`${BASE}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password }), + }), + ); + return response.headers.get("set-auth-token") ?? ""; +}; + +const request = ( + path: string, + options: { readonly method?: string; readonly token?: string; readonly body?: unknown } = {}, +) => + handler( + new Request(`${BASE}${path}`, { + method: options.method ?? "GET", + headers: { + ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), + ...(options.body !== undefined ? { "content-type": "application/json" } : {}), + }, + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), + }), + ); + +test("only the instance admin can manage access groups", async () => { + const adminToken = await signIn( + process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL!, + process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD!, + ); + expect(adminToken).not.toBe(""); + + // A plain member joins through the real invite flow. + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "member@access-groups.test", + password: "password-12345678", + name: "Member", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); + const memberToken = signUp.headers.get("set-auth-token") ?? ""; + expect(memberToken).not.toBe(""); + + // Anonymous → 401; plain member → 403; on reads AND writes. + expect((await request("/api/admin/access-groups")).status).toBe(401); + expect((await request("/api/admin/access-groups", { token: memberToken })).status).toBe(403); + expect( + ( + await request("/api/admin/access-groups", { + method: "POST", + token: memberToken, + body: { name: "finance" }, + }) + ).status, + ).toBe(403); + expect( + ( + await request("/api/admin/access-group-restrictions", { + method: "POST", + token: memberToken, + body: { integration: "github", name: "main", group: "grp_x" }, + }) + ).status, + ).toBe(403); + + // The admin's round trip: create → list → add member → list members → delete. + const created = await request("/api/admin/access-groups", { + method: "POST", + token: adminToken, + body: { name: "finance" }, + }); + expect(created.status).toBe(200); + const group = (await created.json()) as { id: string; name: string }; + expect(group.name).toBe("finance"); + + const listed = await request("/api/admin/access-groups", { token: adminToken }); + expect(listed.status).toBe(200); + expect(((await listed.json()) as { groups: unknown[] }).groups).toHaveLength(1); + + const added = await request(`/api/admin/access-groups/${group.id}/members`, { + method: "POST", + token: adminToken, + body: { subject: "some-user-id" }, + }); + expect(added.status).toBe(200); + + const members = await request(`/api/admin/access-groups/${group.id}/members`, { + token: adminToken, + }); + expect(members.status).toBe(200); + expect( + ((await members.json()) as { members: readonly { subject: string }[] }).members.map( + (member) => member.subject, + ), + ).toEqual(["some-user-id"]); + + // Restricting a connection that doesn't exist is a 404, not a leak of + // anything else. + const missing = await request("/api/admin/access-group-restrictions", { + method: "POST", + token: adminToken, + body: { integration: "github", name: "missing", group: group.id }, + }); + expect(missing.status).toBe(404); + + // Toolkit grants ride the same gate: member is refused, admin round-trips + // against a real toolkit created through the product API. + expect( + ( + await request("/api/admin/access-group-toolkit-restrictions", { + method: "POST", + token: memberToken, + body: { toolkitId: "tk_x", group: group.id }, + }) + ).status, + ).toBe(403); + + const createdToolkit = await request("/api/toolkits", { + method: "POST", + token: adminToken, + body: { owner: "org", name: "Deploy Kit" }, + }); + expect(createdToolkit.status).toBe(200); + const toolkit = (await createdToolkit.json()) as { id: string; slug: string }; + + const granted = await request("/api/admin/access-group-toolkit-restrictions", { + method: "POST", + token: adminToken, + body: { toolkitId: toolkit.id, group: group.id }, + }); + expect(granted.status).toBe(200); + + const toolkitRestrictions = await request("/api/admin/access-group-toolkit-restrictions", { + token: adminToken, + }); + expect(((await toolkitRestrictions.json()) as { restrictions: unknown[] }).restrictions).toEqual([ + { toolkitId: toolkit.id, slug: toolkit.slug, group: group.id }, + ]); + + // Granting to a missing group or a missing toolkit is a 400 with the + // engine's message, not a silent success. + expect( + ( + await request("/api/admin/access-group-toolkit-restrictions", { + method: "POST", + token: adminToken, + body: { toolkitId: toolkit.id, group: "grp_missing" }, + }) + ).status, + ).toBe(400); + expect( + ( + await request("/api/admin/access-group-toolkit-restrictions", { + method: "POST", + token: adminToken, + body: { toolkitId: "tk_missing", group: group.id }, + }) + ).status, + ).toBe(400); + + // Deleting the group while a toolkit still references it is refused — a + // dangling grant would hide the toolkit from everyone. + expect( + (await request(`/api/admin/access-groups/${group.id}`, { method: "DELETE", token: adminToken })) + .status, + ).toBe(400); + + const ungranted = await request(`/api/admin/access-group-toolkit-restrictions/${toolkit.id}`, { + method: "DELETE", + token: adminToken, + }); + expect(ungranted.status).toBe(200); + + const removed = await request(`/api/admin/access-groups/${group.id}`, { + method: "DELETE", + token: adminToken, + }); + expect(removed.status).toBe(200); + const after = await request("/api/admin/access-groups", { token: adminToken }); + expect(((await after.json()) as { groups: unknown[] }).groups).toHaveLength(0); +}); diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 4eaf631f54..b234b9a3b8 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -15,6 +15,7 @@ import { resolveAuthProviders } from "./auth"; import { selfHostDataMigrations } from "./db/data-migrations"; import { makeSelfHostAdminApiLayer } from "./admin/handlers"; import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api"; +import { makeSelfHostAccessGroupsApiLayer } from "./admin/access-groups-handlers"; import { makeSelfHostSystemApiLayer } from "./system/handlers"; import { selfHostAccountMiddleware } from "./account"; import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config"; @@ -133,6 +134,9 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // who uses this instance and what they've connected. Owner/admin-gated, // same as the invite routes above. makeSelfHostAdminUsersApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + // Admin-only access-group management (/api/admin/access-groups*): + // which org connections are restricted to which member groups. + makeSelfHostAccessGroupsApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Public system API: /api/health + /api/setup-status (unauthenticated). makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). diff --git a/packages/core/sdk/src/access-group-enforcement.test.ts b/packages/core/sdk/src/access-group-enforcement.test.ts new file mode 100644 index 0000000000..5617e0fcbf --- /dev/null +++ b/packages/core/sdk/src/access-group-enforcement.test.ts @@ -0,0 +1,328 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; + +import { createExecutor } from "./executor"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, + ProviderKey, + ToolAddress, + ToolName, +} from "./ids"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestConfig, makeTestWorkspaceHarness } from "./testing"; + +// --------------------------------------------------------------------------- +// Access-group ENFORCEMENT invariants — the red-team suite. A restricted org +// connection must behave, for a non-member, exactly like a nonexistent one on +// EVERY read and invoke surface: no listing (even with includeBlocked), no +// schema, no connection read/update/remove, no invoke, no distinguishable +// error, and no policy that re-exposes it. Membership is read live per call. +// The unrestricted views (platform view, subject-less org binding) stay +// deliberately unfiltered. +// --------------------------------------------------------------------------- + +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)), + }; +}; + +const VERCEL = IntegrationSlug.make("vercel"); +const GITHUB = IntegrationSlug.make("github"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const CONN = ConnectionName.make("main"); + +const enforcementTestPlugin = definePlugin(() => ({ + id: "etest" as const, + storage: () => ({}), + credentialProviders: [memoryProvider()], + resolveTools: ({ integration }) => + Effect.succeed({ + tools: + String(integration.slug) === "vercel" + ? [ + { name: ToolName.make("deploy"), description: "deploy" }, + { name: ToolName.make("logs"), description: "read logs" }, + ] + : [{ name: ToolName.make("list"), description: "list repos" }], + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: `${toolRow.integration}.${toolRow.name}` }), + extension: (ctx) => ({ + seed: () => + Effect.gen(function* () { + yield* ctx.core.integrations.register({ slug: VERCEL, description: "Vercel", config: {} }); + yield* ctx.core.integrations.register({ slug: GITHUB, description: "GitHub", config: {} }); + }), + }), +})); + +const plugins = [enforcementTestPlugin()] as const; + +const MEMBER = "member-subject"; +const NON_MEMBER = "non-member-subject"; + +const addr = (integration: IntegrationSlug, tool: string): ToolAddress => + ToolAddress.make(`tools.${integration}.org.${CONN}.${tool}`); + +/** + * Shared-tenant fixture: an admin binding seeds two org connections (vercel + + * github), creates the "finance" group with MEMBER in it, and restricts the + * vercel connection to it. Returns the two subject-bound executors plus the + * admin harness (which manages the group but is NOT a member — its runtime + * view is filtered like everyone's). + */ +const setupRestrictedWorkspace = Effect.gen(function* () { + const dataDir = mkdtempSync(join(tmpdir(), "access-groups-")); + const tenant = "shared-tenant"; + + const admin = yield* makeTestWorkspaceHarness({ plugins, tenant, subject: "admin", dataDir }); + yield* admin.executor.etest.seed(); + for (const integration of [VERCEL, GITHUB]) { + yield* admin.executor.connections.create({ + owner: "org", + name: CONN, + integration, + template: TEMPLATE, + from: { provider: ProviderKey.make("memory"), id: ProviderItemId.make("k") }, + }); + } + const group = yield* admin.executor.accessGroups.create({ name: "finance" }); + yield* admin.executor.accessGroups.addMember({ id: group.id, subject: MEMBER }); + yield* admin.executor.accessGroups.restrictConnection({ + integration: VERCEL, + name: CONN, + group: group.id, + }); + + const member = yield* makeTestWorkspaceHarness({ plugins, tenant, subject: MEMBER, dataDir }); + const nonMember = yield* makeTestWorkspaceHarness({ + plugins, + tenant, + subject: NON_MEMBER, + dataDir, + }); + return { admin, member, nonMember, group, tenant, dataDir }; +}); + +describe("access-group enforcement", () => { + it.effect("hides restricted tools from non-members on tools.list, even includeBlocked", () => + Effect.scoped( + Effect.gen(function* () { + const { member, nonMember } = yield* setupRestrictedWorkspace; + + const memberTools = yield* member.executor.tools.list(); + expect( + memberTools + .filter((tool) => tool.integration === VERCEL) + .map((tool) => String(tool.name)), + ).toEqual(["deploy", "logs"]); + + for (const filter of [undefined, { includeBlocked: true }] as const) { + const tools = yield* nonMember.executor.tools.list(filter); + expect(tools.filter((tool) => tool.integration === VERCEL)).toEqual([]); + // The unrestricted github connection is completely unaffected. + expect( + tools.filter((tool) => tool.integration === GITHUB).map((tool) => String(tool.name)), + ).toEqual(["list"]); + } + }), + ), + ); + + it.effect("hides the restricted connection and its schema from non-members", () => + Effect.scoped( + Effect.gen(function* () { + const { member, nonMember } = yield* setupRestrictedWorkspace; + + const memberConnections = yield* member.executor.connections.list(); + expect( + memberConnections.map((connection) => String(connection.integration)).sort(), + ).toEqual(["github", "vercel"]); + + const nonMemberConnections = yield* nonMember.executor.connections.list(); + expect(nonMemberConnections.map((connection) => String(connection.integration))).toEqual([ + "github", + ]); + + const ref = { owner: "org", integration: VERCEL, name: CONN } as const; + expect(yield* member.executor.connections.get(ref)).not.toBeNull(); + expect(yield* nonMember.executor.connections.get(ref)).toBeNull(); + + expect(yield* member.executor.tools.schema(addr(VERCEL, "deploy"))).not.toBeNull(); + expect(yield* nonMember.executor.tools.schema(addr(VERCEL, "deploy"))).toBeNull(); + }), + ), + ); + + it.effect("refuses non-member update/remove with the nonexistent-connection error", () => + Effect.scoped( + Effect.gen(function* () { + const { member, nonMember } = yield* setupRestrictedWorkspace; + const ref = { owner: "org", integration: VERCEL, name: CONN } as const; + + const updateError = yield* Effect.flip( + nonMember.executor.connections.update(ref, { description: "mine now" }), + ); + expect(Predicate.isTagged("ConnectionNotFoundError")(updateError)).toBe(true); + + const removeError = yield* Effect.flip(nonMember.executor.connections.remove(ref)); + expect(Predicate.isTagged("ConnectionNotFoundError")(removeError)).toBe(true); + // Nothing was deleted: the member still sees it. + expect(yield* member.executor.connections.get(ref)).not.toBeNull(); + }), + ), + ); + + it.effect("invoke fails for non-members exactly like a nonexistent connection", () => + Effect.scoped( + Effect.gen(function* () { + const { member, nonMember } = yield* setupRestrictedWorkspace; + + expect(yield* member.executor.execute(addr(VERCEL, "deploy"), {})).toEqual({ + ran: "vercel.deploy", + }); + + const hidden = yield* Effect.flip(nonMember.executor.execute(addr(VERCEL, "deploy"), {})); + // A connection that genuinely does not exist — the shape the hidden + // answer must be indistinguishable from (no oracle). + const nonexistent = yield* Effect.flip( + nonMember.executor.execute( + ToolAddress.make(`tools.vercel.org.no-such-connection.deploy`), + {}, + ), + ); + expect(Predicate.isTagged("ToolNotFoundError")(hidden)).toBe(true); + expect(Predicate.isTagged("ToolNotFoundError")(nonexistent)).toBe(true); + const suggestionsOf = (error: unknown) => + (error as { readonly suggestions?: readonly unknown[] }).suggestions ?? []; + expect(suggestionsOf(hidden)).toEqual(suggestionsOf(nonexistent)); + expect(suggestionsOf(hidden)).toEqual([]); + + // A wrong tool name on the hidden connection must not leak the + // connection's real tools through suggestions either. + const wrongTool = yield* Effect.flip(nonMember.executor.execute(addr(VERCEL, "nope"), {})); + expect(Predicate.isTagged("ToolNotFoundError")(wrongTool)).toBe(true); + expect(suggestionsOf(wrongTool)).toEqual([]); + }), + ), + ); + + it.effect("an org policy approve does not re-expose a restricted tool", () => + Effect.scoped( + Effect.gen(function* () { + const { nonMember } = yield* setupRestrictedWorkspace; + + yield* nonMember.executor.policies.create({ + owner: "org", + pattern: "vercel.*", + action: "approve", + }); + + const tools = yield* nonMember.executor.tools.list({ includeBlocked: true }); + expect(tools.filter((tool) => tool.integration === VERCEL)).toEqual([]); + const error = yield* Effect.flip(nonMember.executor.execute(addr(VERCEL, "deploy"), {})); + expect(Predicate.isTagged("ToolNotFoundError")(error)).toBe(true); + }), + ), + ); + + it.effect("membership is read live: roster edits apply to an already-open executor", () => + Effect.scoped( + Effect.gen(function* () { + const { admin, nonMember, group } = yield* setupRestrictedWorkspace; + + expect( + (yield* nonMember.executor.tools.list()).filter((tool) => tool.integration === VERCEL), + ).toEqual([]); + + // Grant mid-"session" — the SAME bound executor sees it next call. + yield* admin.executor.accessGroups.addMember({ id: group.id, subject: NON_MEMBER }); + expect( + (yield* nonMember.executor.tools.list()) + .filter((tool) => tool.integration === VERCEL) + .map((tool) => String(tool.name)), + ).toEqual(["deploy", "logs"]); + expect(yield* nonMember.executor.execute(addr(VERCEL, "deploy"), {})).toEqual({ + ran: "vercel.deploy", + }); + + // Revoke — blocked again on the very next call, no rebuild. + yield* admin.executor.accessGroups.removeMember({ id: group.id, subject: NON_MEMBER }); + expect( + (yield* nonMember.executor.tools.list()).filter((tool) => tool.integration === VERCEL), + ).toEqual([]); + const error = yield* Effect.flip(nonMember.executor.execute(addr(VERCEL, "deploy"), {})); + expect(Predicate.isTagged("ToolNotFoundError")(error)).toBe(true); + }), + ), + ); + + it.effect("the admin's runtime view is filtered like everyone's", () => + Effect.scoped( + Effect.gen(function* () { + const { admin } = yield* setupRestrictedWorkspace; + // The admin manages the group but is not a member: the management + // surface still reports the restriction... + expect(yield* admin.executor.accessGroups.restrictions()).toHaveLength(1); + // ...while their runtime catalog hides the connection like any + // non-member's. No is-admin plumbing into the executor. + expect( + (yield* admin.executor.tools.list()).filter((tool) => tool.integration === VERCEL), + ).toEqual([]); + expect( + (yield* admin.executor.connections.list()).map((connection) => + String(connection.integration), + ), + ).toEqual(["github"]); + }), + ), + ); + + it.effect("subject-less and platform-view bindings stay unfiltered", () => + Effect.scoped( + Effect.gen(function* () { + const { tenant, dataDir } = yield* setupRestrictedWorkspace; + + // Subject-less org binding (the platform org API key shape): full org + // visibility — a stated invariant, not fallout. + const subjectless = yield* makeTestWorkspaceHarness({ + plugins, + tenant, + subject: null, + dataDir, + }); + const subjectlessTools = yield* subjectless.executor.tools.list(); + expect( + subjectlessTools + .filter((tool) => tool.integration === VERCEL) + .map((tool) => String(tool.name)), + ).toEqual(["deploy", "logs"]); + expect( + (yield* subjectless.executor.connections.list()) + .map((connection) => String(connection.integration)) + .sort(), + ).toEqual(["github", "vercel"]); + + // The read-only platform view (admin plane) is deliberately + // tenant-wide too. + const platformConfig = makeTestConfig({ plugins, tenant, subject: null, dataDir }); + const platform = yield* createExecutor({ ...platformConfig, platformView: true }); + const platformTools = yield* platform.tools.list(); + expect(platformTools.filter((tool) => tool.integration === VERCEL)).toHaveLength(2); + yield* platform.close().pipe(Effect.ignore); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/access-groups.test.ts b/packages/core/sdk/src/access-groups.test.ts new file mode 100644 index 0000000000..26d82bf8d5 --- /dev/null +++ b/packages/core/sdk/src/access-groups.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, + ProviderKey, + ToolName, +} from "./ids"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestExecutor } from "./testing"; + +// --------------------------------------------------------------------------- +// executor.accessGroups — the management engine. CRUD, idempotent membership, +// and the service-layer referential-integrity rules (no restricting to a +// missing group, no deleting a referenced group; this schema has no FKs). +// Enforcement semantics live in access-group-enforcement.test.ts. +// --------------------------------------------------------------------------- + +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)), + }; +}; + +const VERCEL = IntegrationSlug.make("vercel"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const CONN = ConnectionName.make("main"); + +const groupsTestPlugin = definePlugin(() => ({ + id: "gtest" as const, + storage: () => ({}), + credentialProviders: [memoryProvider()], + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: `${toolRow.integration}.${toolRow.name}` }), + extension: (ctx) => ({ + seed: () => ctx.core.integrations.register({ slug: VERCEL, description: "Vercel", config: {} }), + }), +})); + +const setupExecutor = () => + makeTestExecutor({ plugins: [groupsTestPlugin()] as const }).pipe( + Effect.tap((executor) => + Effect.gen(function* () { + yield* executor.gtest.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: VERCEL, + template: TEMPLATE, + from: { provider: ProviderKey.make("memory"), id: ProviderItemId.make("v") }, + }); + }), + ), + ); + +describe("executor.accessGroups", () => { + it.effect("creates, lists, and renames groups", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + expect(yield* executor.accessGroups.list()).toEqual([]); + + const finance = yield* executor.accessGroups.create({ name: "finance-leads" }); + const ops = yield* executor.accessGroups.create({ name: "ops" }); + expect(finance.name).toBe("finance-leads"); + expect(finance.id).toMatch(/^grp_/); + + const listed = yield* executor.accessGroups.list(); + expect(listed.map((group) => group.name)).toEqual(["finance-leads", "ops"]); + + const renamed = yield* executor.accessGroups.update({ id: ops.id, name: "ops-leads" }); + expect(renamed.name).toBe("ops-leads"); + expect((yield* executor.accessGroups.list()).map((group) => group.name)).toEqual([ + "finance-leads", + "ops-leads", + ]); + }), + ); + + it.effect("rejects empty group names on create and rename", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + const createError = yield* Effect.flip(executor.accessGroups.create({ name: " " })); + expect(Predicate.isTagged("StorageError")(createError)).toBe(true); + + const group = yield* executor.accessGroups.create({ name: "finance" }); + const renameError = yield* Effect.flip( + executor.accessGroups.update({ id: group.id, name: "" }), + ); + expect(Predicate.isTagged("StorageError")(renameError)).toBe(true); + }), + ); + + it.effect("fails renaming or reading members of a missing group", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + const updateError = yield* Effect.flip( + executor.accessGroups.update({ id: "grp_missing", name: "x" }), + ); + expect(Predicate.isTagged("StorageError")(updateError)).toBe(true); + const membersError = yield* Effect.flip(executor.accessGroups.members("grp_missing")); + expect(Predicate.isTagged("StorageError")(membersError)).toBe(true); + }), + ); + + it.effect("membership add is idempotent; remove of an absent member is a no-op", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + const group = yield* executor.accessGroups.create({ name: "finance" }); + + const first = yield* executor.accessGroups.addMember({ id: group.id, subject: "user_a" }); + const again = yield* executor.accessGroups.addMember({ id: group.id, subject: "user_a" }); + expect(first.subject).toBe("user_a"); + expect(again.createdAt.getTime()).toBe(first.createdAt.getTime()); + yield* executor.accessGroups.addMember({ id: group.id, subject: "user_b" }); + + const members = yield* executor.accessGroups.members(group.id); + expect(members.map((member) => member.subject)).toEqual(["user_a", "user_b"]); + + // Absent-member removal is a no-op, so an offboarding sweep can + // best-effort delete without existence checks. + yield* executor.accessGroups.removeMember({ id: group.id, subject: "user_gone" }); + yield* executor.accessGroups.removeMember({ id: group.id, subject: "user_b" }); + expect( + (yield* executor.accessGroups.members(group.id)).map((member) => member.subject), + ).toEqual(["user_a"]); + }), + ); + + it.effect("rejects blank member subjects", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + const group = yield* executor.accessGroups.create({ name: "finance" }); + const error = yield* Effect.flip( + executor.accessGroups.addMember({ id: group.id, subject: " " }), + ); + expect(Predicate.isTagged("StorageError")(error)).toBe(true); + }), + ); + + it.effect("restricts and unrestricts an org connection", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + const group = yield* executor.accessGroups.create({ name: "finance" }); + + expect(yield* executor.accessGroups.restrictions()).toEqual([]); + yield* executor.accessGroups.restrictConnection({ + integration: VERCEL, + name: CONN, + group: group.id, + }); + const restrictions = yield* executor.accessGroups.restrictions(); + expect(restrictions).toEqual([{ integration: VERCEL, name: CONN, group: group.id }]); + + yield* executor.accessGroups.unrestrictConnection({ integration: VERCEL, name: CONN }); + expect(yield* executor.accessGroups.restrictions()).toEqual([]); + }), + ); + + it.effect("rejects restricting to an unknown group and restricting a missing connection", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + const unknownGroup = yield* Effect.flip( + executor.accessGroups.restrictConnection({ + integration: VERCEL, + name: CONN, + group: "grp_missing", + }), + ); + expect(Predicate.isTagged("StorageError")(unknownGroup)).toBe(true); + + const group = yield* executor.accessGroups.create({ name: "finance" }); + const missingConnection = yield* Effect.flip( + executor.accessGroups.restrictConnection({ + integration: VERCEL, + name: ConnectionName.make("missing"), + group: group.id, + }), + ); + expect(Predicate.isTagged("ConnectionNotFoundError")(missingConnection)).toBe(true); + const unrestrictMissing = yield* Effect.flip( + executor.accessGroups.unrestrictConnection({ + integration: VERCEL, + name: ConnectionName.make("missing"), + }), + ); + expect(Predicate.isTagged("ConnectionNotFoundError")(unrestrictMissing)).toBe(true); + }), + ); + + it.effect("refuses deleting a group while a connection references it, then deletes cleanly", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + const group = yield* executor.accessGroups.create({ name: "finance" }); + yield* executor.accessGroups.addMember({ id: group.id, subject: "user_a" }); + yield* executor.accessGroups.restrictConnection({ + integration: VERCEL, + name: CONN, + group: group.id, + }); + + // A dangling group reference would silently hide the connection from + // everyone — deletion is refused while referenced. + const blocked = yield* Effect.flip(executor.accessGroups.remove({ id: group.id })); + expect(Predicate.isTagged("StorageError")(blocked)).toBe(true); + expect(yield* executor.accessGroups.restrictions()).toHaveLength(1); + + yield* executor.accessGroups.unrestrictConnection({ integration: VERCEL, name: CONN }); + yield* executor.accessGroups.remove({ id: group.id }); + expect(yield* executor.accessGroups.list()).toEqual([]); + // Member rows are gone with the group. + const membersError = yield* Effect.flip(executor.accessGroups.members(group.id)); + expect(Predicate.isTagged("StorageError")(membersError)).toBe(true); + }), + ); +}); diff --git a/packages/core/sdk/src/access-groups.ts b/packages/core/sdk/src/access-groups.ts new file mode 100644 index 0000000000..cc662aaf70 --- /dev/null +++ b/packages/core/sdk/src/access-groups.ts @@ -0,0 +1,83 @@ +// --------------------------------------------------------------------------- +// Access groups — named audiences of org members that gate org-owned +// connections. A connection carrying `access_group` is a HARD visibility +// boundary: to a non-member it does not exist on any read or invoke surface +// (no distinguishable error, no existence oracle). Groups are tenant-scoped +// first-class rows here; the hosts stay authoritative for who the members ARE +// (names, emails) — `subject` values are the same host-auth principal ids the +// owned tables partition by. +// +// Management is an ADMIN surface: these inputs are consumed by +// `executor.accessGroups`, which hosts expose only behind their own admin +// gates — never through the any-member `ExecutorApi`. +// --------------------------------------------------------------------------- + +import type { AccessGroupMemberRow, AccessGroupRow } from "./core-schema"; +import { AccessGroupId, type ConnectionName, type IntegrationSlug } from "./ids"; + +export interface AccessGroup { + readonly id: AccessGroupId; + readonly name: string; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface AccessGroupMember { + readonly groupId: AccessGroupId; + /** The host-auth principal id (cloud: the WorkOS accountId). Opaque. */ + readonly subject: string; + readonly createdAt: Date; +} + +export interface CreateAccessGroupInput { + readonly name: string; +} + +export interface UpdateAccessGroupInput { + readonly id: string; + readonly name: string; +} + +export interface RemoveAccessGroupInput { + readonly id: string; +} + +export interface AccessGroupMemberInput { + readonly id: string; + /** The member's host-auth principal id (the org member's account id). */ + readonly subject: string; +} + +/** Restriction targets are always org-owned connections — restricting a + * personal connection is rejected (it is already invisible to everyone + * else), so the ref carries no owner. */ +export interface RestrictConnectionInput { + readonly integration: IntegrationSlug; + readonly name: ConnectionName; + readonly group: string; +} + +export interface UnrestrictConnectionInput { + readonly integration: IntegrationSlug; + readonly name: ConnectionName; +} + +/** One restricted org connection, as the management surface reports it. */ +export interface RestrictedConnection { + readonly integration: IntegrationSlug; + readonly name: ConnectionName; + readonly group: AccessGroupId; +} + +export const rowToAccessGroup = (row: AccessGroupRow): AccessGroup => ({ + id: AccessGroupId.make(row.id), + name: row.name, + createdAt: row.created_at, + updatedAt: row.updated_at, +}); + +export const rowToAccessGroupMember = (row: AccessGroupMemberRow): AccessGroupMember => ({ + groupId: AccessGroupId.make(row.group_id), + subject: row.subject, + createdAt: row.created_at, +}); diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index b03adf5dfc..e42e168807 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -197,6 +197,36 @@ export const coreTables = defineTables({ ["tenant", "external_id"], ), + // A named audience of org members ("finance-leads"). Tenant-scoped like + // `subject`: groups are org-level objects, not per-owner rows, and the + // admin plane needs to read them tenant-wide with zero policy changes. + // Referential integrity with `connection.access_group` is a service-layer + // concern (this schema has no FK machinery): group deletion is rejected + // while any connection references the group. + access_group: tenantExecutorTable( + "access_group", + { + id: keyColumn("id"), + name: textColumn("name"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "id"], + ), + + // Group membership — one row per (group, subject). `subject` is the same + // host-auth principal id the owned tables partition by (`subject.external_id`). + // Manual rosters only: a future directory-sync job writes here additively. + access_group_member: tenantExecutorTable( + "access_group_member", + { + group_id: keyColumn("group_id"), + subject: keyColumn("subject"), + created_at: dateColumn("created_at"), + }, + ["tenant", "group_id", "subject"], + ), + // 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 @@ -237,6 +267,12 @@ export const coreTables = defineTables({ // callback). Null means refresh uses the oauth_client's `token_url`. oauth_token_url: nullableTextColumn("oauth_token_url"), provider_state: nullableJsonColumn("provider_state"), + // Access-group restriction: the `access_group.id` whose members may see + // and invoke this connection; null = unrestricted (every org member, the + // pre-existing semantics). Only meaningful on org-owned rows — the + // service layer rejects restricting a personal connection. Nullable is + // load-bearing: SQLite boot-ensure hosts cannot add NOT NULL columns. + access_group: nullableKeyColumn("access_group"), created_at: dateColumn("created_at"), updated_at: dateColumn("updated_at"), }, @@ -432,6 +468,8 @@ export type CoreSchema = typeof coreTables; export type IntegrationRow = FumaRow; export type SubjectRow = FumaRow; export type ConnectionRow = FumaRow; +export type AccessGroupRow = FumaRow; +export type AccessGroupMemberRow = FumaRow; export type OAuthClientRow = FumaRow; export type OAuthSessionRow = FumaRow; export type ToolRow = FumaRow; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 4a962c3ab6..6abdcdf32c 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -79,6 +79,20 @@ import { type ExecuteError, } from "./errors"; import { + rowToAccessGroup, + rowToAccessGroupMember, + type AccessGroup, + type AccessGroupMember, + type AccessGroupMemberInput, + type CreateAccessGroupInput, + type RemoveAccessGroupInput, + type RestrictConnectionInput, + type RestrictedConnection, + type UnrestrictConnectionInput, + type UpdateAccessGroupInput, +} from "./access-groups"; +import { + AccessGroupId, ArtifactId, AuthTemplateSlug, ConnectionAddress, @@ -385,6 +399,36 @@ export type Executor = { readonly resolve: (address: ToolAddress) => Effect.Effect; }; + /** + * Access groups — named audiences of org members gating org-owned + * connections. A MANAGEMENT surface: hosts expose it only behind their own + * admin gates; it is deliberately absent from the shared any-member HTTP + * API. Enforcement (a restricted connection is invisible and uninvokable + * for non-members, with no existence oracle) rides on `tools`, + * `connections`, and `execute` automatically. + */ + readonly accessGroups: { + readonly list: () => Effect.Effect; + readonly create: (input: CreateAccessGroupInput) => Effect.Effect; + readonly update: (input: UpdateAccessGroupInput) => Effect.Effect; + /** Fails while any connection still references the group. */ + readonly remove: (input: RemoveAccessGroupInput) => Effect.Effect; + readonly members: (id: string) => Effect.Effect; + /** Idempotent; `subject` is the org member's host account id. */ + readonly addMember: ( + input: AccessGroupMemberInput, + ) => Effect.Effect; + readonly removeMember: (input: AccessGroupMemberInput) => Effect.Effect; + /** Org-owned connections only — the input carries no owner on purpose. */ + readonly restrictConnection: ( + input: RestrictConnectionInput, + ) => Effect.Effect; + readonly unrestrictConnection: ( + input: UnrestrictConnectionInput, + ) => Effect.Effect; + readonly restrictions: () => Effect.Effect; + }; + /** * The PLATFORM VIEW: read-only, tenant-wide reads across every subject. * Present only when the executor was built with `platformView: true` @@ -3058,7 +3102,13 @@ export const createExecutor = | null, StorageFailure> => + config.platformView === true || subject == null + ? Effect.succeed(null) + : core + .findMany("access_group_member", { + where: (b: AnyCb) => b("subject", "=", subject), + }) + .pipe(Effect.map((rows) => new Set(rows.map((row) => row.group_id)))); + + /** May a view holding `groups` use a connection whose `access_group` + * column is `accessGroup`? Null column = unrestricted row; null groups = + * unrestricted view. */ + const subjectMayUseConnection = ( + groups: ReadonlySet | null, + accessGroup: unknown, + ): boolean => groups === null || accessGroup == null || groups.has(String(accessGroup)); + + const restrictedConnectionKey = (owner: string, integration: string, connection: string) => + `${owner}:${integration}:${connection}`; + + /** `owner:integration:name → access_group` for every restricted + * connection — the cross-reference the tool surfaces need, since tool + * rows don't carry the column. Bounded: restricted connections are a + * small subset of the org catalog. */ + const loadRestrictedConnections = (): Effect.Effect< + ReadonlyMap, + StorageFailure + > => + core + .findMany("connection", { + where: (b: AnyCb) => b.isNotNull("access_group"), + select: ["owner", "integration", "name", "access_group"], + }) + .pipe( + Effect.map( + (rows) => + new Map( + rows.map( + (row) => + [ + restrictedConnectionKey(row.owner, row.integration, row.name), + String(row.access_group), + ] as const, + ), + ), + ), + ); + + /** The tool-surface predicate: hides a tool row whose connection is + * restricted to a group this view is not in. Returns a constant-true + * predicate (no restricted-connections query) for unrestricted views. */ + const loadHiddenToolRowPredicate = (): Effect.Effect< + (row: { + readonly owner: string; + readonly integration: string; + readonly connection: string; + }) => boolean, + StorageFailure + > => + Effect.gen(function* () { + const groups = yield* loadSubjectGroupIds(); + if (groups === null) return () => false; + const restricted = yield* loadRestrictedConnections(); + if (restricted.size === 0) return () => false; + return (row: { + readonly owner: string; + readonly integration: string; + readonly connection: string; + }) => { + const group = restricted.get( + restrictedConnectionKey(row.owner, row.integration, row.connection), + ); + return group !== undefined && !groups.has(group); + }; + }); + + /** `findConnectionRow` with the access-group gate applied: a restricted + * row this view may not use resolves to `null`, exactly like a + * nonexistent one. The gated variant backs every caller-addressed + * connection surface (get/update/remove/refresh/checkHealth); the raw + * reader stays in place for create/mint existence checks and the + * background catalog sync, which must see rows regardless of the + * current caller's memberships. */ + const findVisibleConnectionRow = ( + ref: ConnectionRef, + ): Effect.Effect => + Effect.gen(function* () { + const row = yield* findConnectionRow(ref); + if (!row) return null; + const groups = yield* loadSubjectGroupIds(); + return subjectMayUseConnection(groups, row.access_group) ? row : null; + }); + + /** The plugin-facing `connections.resolveValue` seam, gated: resolving a + * restricted connection's credential from a non-member binding returns + * `null` (the nonexistent-connection answer), never the value. */ + const resolveVisibleConnectionValue = ( + ref: ConnectionRef, + ): Effect.Effect => + foldResolutionFailure( + Effect.gen(function* () { + const row = yield* findVisibleConnectionRow(ref); + if (!row) return null; + return yield* resolveConnectionValue(row); + }), + ); + // ------------------------------------------------------------------ // Tools (read surface) // ------------------------------------------------------------------ @@ -3697,8 +3874,14 @@ export const createExecutor =