diff --git a/.changeset/fumadb-bounded-mysql-ids.md b/.changeset/fumadb-bounded-mysql-ids.md new file mode 100644 index 0000000000..67dc254980 --- /dev/null +++ b/.changeset/fumadb-bounded-mysql-ids.md @@ -0,0 +1,5 @@ +--- +"@executor-js/fumadb": major +--- + +MySQL Drizzle schema generation now rejects unbounded string primary keys instead of emitting invalid `text` primary-key SQL. Before upgrading, change every MySQL `idColumn(..., "string")` to an explicit bound such as `idColumn(..., "varchar(255)")` (or use `"uuid"`). The `IdColumnType` type is now exported from `@executor-js/fumadb/schema` for reusable schema helpers. diff --git a/.changeset/workspace-audit-history.md b/.changeset/workspace-audit-history.md new file mode 100644 index 0000000000..64eb911433 --- /dev/null +++ b/.changeset/workspace-audit-history.md @@ -0,0 +1,17 @@ +--- +"@executor-js/sdk": minor +"@executor-js/api": minor +--- + +**Workspace audit history** + +Connection, integration, OAuth-client, and tool-policy mutations now append +tenant-scoped audit events containing only actor and safe resource identifiers. +Admins can inspect the history in the Users page Activity tab or through +`GET /admin/audit-events`. + +The history records `created`, `updated`, and `removed` row intent. When +post-commit credential persistence fails, successful row compensation appends +`rolled_back`; a later provider-cleanup failure appends `rollback_failed`, so +the history never claims complete compensation when credential restoration was +incomplete. diff --git a/apps/cloud/drizzle/0018_redundant_forge.sql b/apps/cloud/drizzle/0018_redundant_forge.sql new file mode 100644 index 0000000000..25f49f5ca3 --- /dev/null +++ b/apps/cloud/drizzle/0018_redundant_forge.sql @@ -0,0 +1,14 @@ +CREATE TABLE "audit_event" ( + "id" text NOT NULL, + "actor_id" text, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_owner" text, + "resource_parent" text, + "resource_id" text NOT NULL, + "created_at" timestamp NOT NULL, + "row_id" text PRIMARY KEY NOT NULL, + "tenant" text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "audit_event_uidx" ON "audit_event" USING btree ("tenant","created_at","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0018_snapshot.json b/apps/cloud/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000000..1d5484baf4 --- /dev/null +++ b/apps/cloud/drizzle/meta/0018_snapshot.json @@ -0,0 +1,1605 @@ +{ + "id": "92fca4ff-d41d-4120-ba50-649fece7da4d", + "prevId": "42251aa3-ae24-4010-ac65-9f41e26cdc20", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_event": { + "name": "audit_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_owner": { + "name": "resource_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_parent": { + "name": "resource_parent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_event_uidx": { + "name": "audit_event_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 375397ceca..22440fe1a8 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1788287088210, "tag": "0017_lush_thunderbolts", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1788287458967, + "tag": "0018_redundant_forge", + "breakpoints": true } ] } diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts index cae66fcb5f..68b8a8bc05 100644 --- a/apps/cloud/src/admin/admin-users-api.ts +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -35,6 +35,7 @@ import { HostConfig, PluginsProvider, getAdminUser, + listAdminAuditEvents, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, @@ -286,6 +287,14 @@ export const workosAdminUsersProvider: Layer.Layer< WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig >(); return AdminUsersProvider.of({ + listAuditEvents: (headers, options) => + withPlatformView(headers, (executor, organizationId) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + listAdminAuditEvents(admin, options, userDirectory(organizationId, context)), + ), + ), + ).pipe(Effect.provideContext(context)), listUsers: (headers, options) => withPlatformView(headers, (executor, organizationId) => platformViewOf(executor).pipe( diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 0db709b884..6d3177631e 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -49,6 +49,26 @@ export const subject = pgTable( (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)], ); +export const audit_event = pgTable( + "audit_event", + { + id: text("id").notNull(), + actor_id: text("actor_id"), + action: text("action").notNull(), + resource_type: text("resource_type").notNull(), + resource_owner: text("resource_owner"), + resource_parent: text("resource_parent"), + resource_id: text("resource_id").notNull(), + created_at: timestamp("created_at").notNull(), + row_id: text("row_id") + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: text("tenant").notNull(), + }, + (table) => [uniqueIndex("audit_event_uidx").on(table.tenant, table.created_at, table.id)], +); + export const connection = pgTable( "connection", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index 86faa45bb9..ada5d4ed21 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -29,6 +29,7 @@ import * as executorSchema from "./executor-schema"; import { memberships, accounts } from "./schema"; import { artifact, + audit_event, blob, connection, definition, @@ -147,6 +148,18 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { tenant, }); + await db.insert(audit_event).values({ + id: `aud-${tag}`, + actor_id: `acct-${tag}`, + action: "created", + resource_type: "connection", + resource_owner: "org", + resource_parent: "int", + resource_id: `conn-${tag}`, + created_at: now, + tenant, + }); + await db.insert(artifact).values({ id: `art-${tag}`, title: "Dashboard", @@ -184,6 +197,7 @@ const TENANT_TABLES = [ tool_policy, plugin_storage, subject, + audit_event, artifact, ] as const; diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index b2a922a3fd..97a7585aec 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -17,6 +17,7 @@ import type { DrizzleDb } from "./db"; import { organizations } from "./schema"; import { artifact, + audit_event, blob, connection, definition, @@ -50,6 +51,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); + await tx.delete(audit_event).where(eq(audit_event.tenant, organizationId)); await tx.delete(subject).where(eq(subject.tenant, organizationId)); await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); diff --git a/apps/host-selfhost/src/admin/admin-users-api.ts b/apps/host-selfhost/src/admin/admin-users-api.ts index 38f767a168..4f11b8fbb5 100644 --- a/apps/host-selfhost/src/admin/admin-users-api.ts +++ b/apps/host-selfhost/src/admin/admin-users-api.ts @@ -28,6 +28,7 @@ import { HostConfig, PluginsProvider, getAdminUser, + listAdminAuditEvents, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, @@ -159,6 +160,14 @@ export const betterAuthAdminUsersProvider: Layer.Layer< const context = yield* Effect.context(); const { auth, organizationId } = yield* BetterAuth; return AdminUsersProvider.of({ + listAuditEvents: (headers, options) => + withPlatformView(headers, organizationId, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + listAdminAuditEvents(admin, options, userDirectory(auth, headers)), + ), + ), + ).pipe(Effect.provideContext(context)), listUsers: (headers, options) => withPlatformView(headers, organizationId, (executor) => platformViewOf(executor).pipe( diff --git a/e2e/cloud/admin-users-console.test.ts b/e2e/cloud/admin-users-console.test.ts index 4882e35985..49f20433be 100644 --- a/e2e/cloud/admin-users-console.test.ts +++ b/e2e/cloud/admin-users-console.test.ts @@ -17,6 +17,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; +import { AdminUsersHttpApi } from "@executor-js/api"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; @@ -107,6 +108,14 @@ scenario( ); const adminConnection = freshConnectionName(); const memberConnection = freshConnectionName(); + const auditPolicy = yield* adminClient.policies.create({ + payload: { + owner: "org", + pattern: `executor.${connectedIntegration}.audit`, + action: "approve", + }, + }); + yield* Effect.ensuring( Effect.gen(function* () { // Both roles may store Personal credentials. A member cannot promote @@ -142,6 +151,34 @@ scenario( }, }); + const auditClient = yield* apiClient(AdminUsersHttpApi, admin); + const audit = yield* auditClient.adminUsers.listAuditEvents({ query: { limit: 100 } }); + expect( + audit.events.some( + (event) => event.resourceType === "tool_policy" && event.resourceId === auditPolicy.id, + ), + "the admin audit API includes the tool-policy event", + ).toBe(true); + expect( + audit.events.some( + (event) => event.resourceType === "connection" && event.resourceId === memberConnection, + ), + "the admin audit API includes the Personal connection event", + ).toBe(true); + const auditPayload = JSON.stringify(audit); + expect(auditPayload, "audit events never contain the member credential").not.toContain( + "member-personal-token", + ); + expect(auditPayload, "audit events never contain the admin credential").not.toContain( + "admin-personal-token", + ); + const memberAudit = yield* Effect.promise(() => + fetch(new URL("/api/admin/audit-events", target.baseUrl), { + headers: member.headers, + }), + ); + expect(memberAudit.status, "a member cannot read workspace audit history").toBe(403); + // ── The admin's view ──────────────────────────────────────────────── yield* browser.session(forBrowser(admin), async ({ page, step }) => { let slug = ""; @@ -355,6 +392,18 @@ scenario( await page.keyboard.press("Escape"); await page.keyboard.press("Escape"); }); + + await step("The Activity tab renders the tool-policy event", async () => { + await visit(page, `/${slug}/users`); + await page.getByRole("button", { name: "Activity", exact: true }).click(); + await page.locator("[data-slot='admin-audit-table']").waitFor({ + state: "visible", + timeout: 30_000, + }); + await page + .getByText(`Tool policy: ${auditPolicy.id}`, { exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); + }); }); // ── The plain member's view ───────────────────────────────────────── @@ -446,6 +495,9 @@ scenario( params: { owner: "user", integration: connectedIntegration, name: memberConnection }, }) .pipe(Effect.ignore), + adminClient.policies + .remove({ params: { policyId: auditPolicy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), adminClient.openapi .removeSpec({ params: { slug: connectedIntegration } }) .pipe(Effect.ignore), diff --git a/e2e/selfhost/admin-users-console.test.ts b/e2e/selfhost/admin-users-console.test.ts index 906e699429..1df16b1059 100644 --- a/e2e/selfhost/admin-users-console.test.ts +++ b/e2e/selfhost/admin-users-console.test.ts @@ -13,6 +13,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; +import { AdminUsersHttpApi } from "@executor-js/api"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; @@ -92,6 +93,14 @@ scenario( // guaranteed to render at least one connect link to assert the shape of. const availableIntegration = yield* registerIntegration(ownerClient, "admin-ui-sh-avail"); const memberConnection = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); + const auditPolicy = yield* ownerClient.policies.create({ + payload: { + owner: "org", + pattern: `executor.${integration}.audit`, + action: "approve", + }, + }); + yield* Effect.ensuring( Effect.gen(function* () { // Members may add Personal credentials, but the API refuses the same @@ -118,6 +127,33 @@ scenario( }, }); + const auditClient = yield* apiClient(AdminUsersHttpApi, owner); + const audit = yield* auditClient.adminUsers.listAuditEvents({ query: { limit: 100 } }); + expect( + audit.events.some( + (event) => event.resourceType === "tool_policy" && event.resourceId === auditPolicy.id, + ), + "the self-host audit API includes the tool-policy event", + ).toBe(true); + expect( + audit.events.some( + (event) => event.resourceType === "connection" && event.resourceId === memberConnection, + ), + "the self-host audit API includes the Personal connection event", + ).toBe(true); + expect( + JSON.stringify(audit), + "self-host audit events never contain credentials", + ).not.toContain("member-personal-token"); + const memberAudit = yield* Effect.promise(() => + fetch(new URL("/api/admin/audit-events", target.baseUrl), { + headers: member.headers, + }), + ); + expect(memberAudit.status, "a self-host member cannot read workspace audit history").toBe( + 403, + ); + yield* browser.session(owner, async ({ page, step }) => { await step("Open Users from the sidebar as the instance owner", async () => { await visit(page, "/"); @@ -298,6 +334,19 @@ scenario( "the link lands in the connect flow, not just on the page", ).toBe("1"); }); + + await step("The Activity tab renders the tool-policy event", async () => { + await page.keyboard.press("Escape"); + await visit(page, "/users"); + await page.getByRole("button", { name: "Activity", exact: true }).click(); + await page.locator("[data-slot='admin-audit-table']").waitFor({ + state: "visible", + timeout: 30_000, + }); + await page + .getByText(`Tool policy: ${auditPolicy.id}`, { exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); + }); }); yield* browser.session(member, async ({ page, step }) => { @@ -363,6 +412,9 @@ scenario( memberClient.connections .remove({ params: { owner: "user", integration, name: memberConnection } }) .pipe(Effect.ignore), + ownerClient.policies + .remove({ params: { policyId: auditPolicy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), ownerClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore), ownerClient.openapi .removeSpec({ params: { slug: availableIntegration } }) diff --git a/packages/core/api/src/admin/admin-users.test.ts b/packages/core/api/src/admin/admin-users.test.ts index aec566c295..847f864895 100644 --- a/packages/core/api/src/admin/admin-users.test.ts +++ b/packages/core/api/src/admin/admin-users.test.ts @@ -20,6 +20,7 @@ import { AdminUsersHandlers } from "./handlers"; import { AdminUsersProvider, type AdminUsersHeaders } from "./service"; import { getUser, + listAuditEvents, listUserConnections, listUsers, listUsersWithConnections, @@ -158,6 +159,40 @@ const insertConnection = ( }); }); +const insertAuditEvent = ( + db: SqliteTestFumaDb, + row: { + readonly id: string; + readonly tenant: string; + readonly actorId: string | null; + readonly action: "created" | "updated" | "removed" | "rolled_back" | "rollback_failed"; + readonly resourceType: "connection" | "integration" | "oauth_client"; + readonly resourceOwner: "org" | "user" | null; + readonly resourceId: string; + readonly createdAt: number; + }, +): Effect.Effect => + Effect.promise(async () => { + await db.client.execute({ + sql: `INSERT INTO audit_event ( + row_id, tenant, id, actor_id, action, resource_type, resource_owner, + resource_parent, resource_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + `row-${row.id}`, + row.tenant, + row.id, + row.actorId, + row.action, + row.resourceType, + row.resourceOwner, + row.resourceType === "connection" ? "github" : null, + row.resourceId, + row.createdAt, + ], + }); + }); + /** Two users with connections under tenant A, plus a whole separate tenant B * that A's admin plane must never see. */ const seed = (db: SqliteTestFumaDb): Effect.Effect => @@ -231,6 +266,15 @@ const stubProvider = ( directory?: AdminIdentityDirectory | AdminUserDirectory, ) => Layer.succeed(AdminUsersProvider)({ + listAuditEvents: (headers, options) => + authorize(headers).pipe( + Effect.flatMap(executorFor), + Effect.flatMap((executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => listAuditEvents(admin, options, directory)), + ), + ), + ), listUsers: (headers, options) => authorize(headers).pipe( Effect.flatMap(executorFor), @@ -359,6 +403,21 @@ type UsersWithConnectionsBody = { }>; }>; }; +type AuditEventsBody = { + readonly events: ReadonlyArray<{ + readonly id: string; + readonly actorId: string | null; + readonly actorEmail: string | null; + readonly actorDisplayName: string | null; + readonly action: string; + readonly resourceType: string; + readonly resourceOwner: string | null; + readonly resourceParent: string | null; + readonly resourceId: string; + readonly createdAt: number; + }>; +}; + const ORG_A = "Bearer org_a_key"; /** @@ -419,6 +478,75 @@ const failingDirectory: AdminIdentityDirectory = () => Effect.fail(new DirectoryUnavailable({ message: "member directory unavailable" })); describe("admin users API", () => { + it.effect("lists filtered audit events with actor identity and tenant isolation", () => + withDb((db) => + Effect.gen(function* () { + yield* insertAuditEvent(db, { + id: "aud-a-old", + tenant: TENANT_A, + actorId: USER_A1, + action: "created", + resourceType: "connection", + resourceOwner: "org", + resourceId: "shared", + createdAt: 100, + }); + yield* insertAuditEvent(db, { + id: "aud-a-new", + tenant: TENANT_A, + actorId: USER_A1, + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceId: "personal", + createdAt: 200, + }); + yield* insertAuditEvent(db, { + id: "aud-b", + tenant: TENANT_B, + actorId: USER_B1, + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceId: "other-tenant-secret-name", + createdAt: 300, + }); + + const seen: string[][] = []; + const web = yield* webHandlerFor( + stubProvider( + (tenant) => platformExecutorFor(db, tenant), + headerAuthorize, + stubUserDirectory({ seen }), + ), + ); + const response = yield* get( + web, + "/admin/audit-events?action=removed&resourceOwner=user&limit=1", + ORG_A, + ); + expect(response.status).toBe(200); + const body = yield* jsonOf(response); + expect(body.events).toEqual([ + { + id: "aud-a-new", + actorId: USER_A1, + actorEmail: A1_EMAIL_STORED, + actorDisplayName: "User A1", + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceParent: "github", + resourceId: "personal", + createdAt: 200_000, + }, + ]); + expect(seen).toEqual([[USER_A1]]); + expect(JSON.stringify(body)).not.toContain("other-tenant-secret-name"); + }), + ), + ); + it.effect("lists every user of the tenant for an authorized org caller", () => withDb((db) => Effect.gen(function* () { @@ -498,6 +626,7 @@ describe("admin users API", () => { ); for (const path of [ + "/admin/audit-events", "/admin/users", "/admin/users/with-connections", `/admin/users/${USER_A1}/connections`, @@ -569,6 +698,9 @@ describe("admin users API", () => { const member = yield* get(web, "/admin/users", "Bearer user_scoped_key"); expect(member.status, "a non-admin caller → forbidden").toBe(403); + expect((yield* get(web, "/admin/audit-events")).status).toBe(401); + expect((yield* get(web, "/admin/audit-events", "Bearer user_scoped_key")).status).toBe(403); + const memberJoined = yield* get(web, "/admin/users/with-connections", "Bearer user_key"); expect(memberJoined.status).toBe(403); const memberConnections = yield* get( @@ -1096,6 +1228,10 @@ const A_SUBJECT: AdminSubject = { /** An `ExecutorAdmin` that answers everything and records the reads it was * asked for, so a test can assert the call the filter chose. */ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ + listAuditEvents: () => { + calls.push("listAuditEvents"); + return Effect.succeed([]); + }, listSubjects: () => { calls.push("listSubjects"); return Effect.succeed([A_SUBJECT]); diff --git a/packages/core/api/src/admin/api.ts b/packages/core/api/src/admin/api.ts index 69e76db94d..ba9b7c3781 100644 --- a/packages/core/api/src/admin/api.ts +++ b/packages/core/api/src/admin/api.ts @@ -37,6 +37,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect"; import { ConnectionName, HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared"; +import { AUDIT_EVENT_ACTIONS, AUDIT_RESOURCE_TYPES } from "@executor-js/sdk"; // --------------------------------------------------------------------------- // Errors @@ -196,6 +197,24 @@ export const AdminUserResponse = Schema.Struct({ user: AdminUserWithConnections, }); +export const AdminAuditEvent = Schema.Struct({ + id: Schema.String, + actorId: Schema.NullOr(Schema.String), + actorEmail: Schema.NullOr(Schema.String), + actorDisplayName: Schema.NullOr(Schema.String), + action: Schema.Literals(AUDIT_EVENT_ACTIONS), + resourceType: Schema.Literals(AUDIT_RESOURCE_TYPES), + resourceOwner: Schema.NullOr(Owner), + resourceParent: Schema.NullOr(Schema.String), + resourceId: Schema.String, + /** Epoch milliseconds. */ + createdAt: Schema.Number, +}); + +export const AdminAuditEventsResponse = Schema.Struct({ + events: Schema.Array(AdminAuditEvent), +}); + // --------------------------------------------------------------------------- // Params / query // --------------------------------------------------------------------------- @@ -274,6 +293,22 @@ const AdminListQuery = Schema.Struct({ email: Schema.optional(Schema.String), }); +const AdminAuditListQuery = Schema.Struct({ + limit: Schema.optional( + Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })), + ), + offset: Schema.optional( + Schema.FiniteFromString.check( + Schema.isInt(), + Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), + ), + ), + actorId: Schema.optional(Schema.String), + action: Schema.optional(Schema.Literals(AUDIT_EVENT_ACTIONS)), + resourceType: Schema.optional(Schema.Literals(AUDIT_RESOURCE_TYPES)), + resourceOwner: Schema.optional(Owner), +}); + // --------------------------------------------------------------------------- // Group // --------------------------------------------------------------------------- @@ -304,6 +339,13 @@ const AdminListQuery = Schema.Struct({ * same position, and the tree matches on position, not on name. */ export const AdminUsersApi = HttpApiGroup.make("adminUsers") + .add( + HttpApiEndpoint.get("listAuditEvents", "/admin/audit-events", { + query: AdminAuditListQuery, + success: AdminAuditEventsResponse, + error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden], + }), + ) .add( HttpApiEndpoint.get("listUsers", "/admin/users", { query: AdminListQuery, diff --git a/packages/core/api/src/admin/handlers.ts b/packages/core/api/src/admin/handlers.ts index f5d6ccc8b9..078983b90b 100644 --- a/packages/core/api/src/admin/handlers.ts +++ b/packages/core/api/src/admin/handlers.ts @@ -1,6 +1,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest } from "effect/unstable/http"; import { Effect } from "effect"; +import type { + AdminListAuditEventsOptions, + AuditEventAction, + AuditResourceType, + Owner, +} from "@executor-js/sdk"; import { AdminUsersHttpApi } from "./api"; import { normalizeEmail } from "./reads"; @@ -34,11 +40,36 @@ const listOptions = (query: { ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), }); +const auditListOptions = (query: { + readonly limit?: number | undefined; + readonly offset?: number | undefined; + readonly actorId?: string | undefined; + readonly action?: AuditEventAction | undefined; + readonly resourceType?: AuditResourceType | undefined; + readonly resourceOwner?: Owner | undefined; +}): AdminListAuditEventsOptions => ({ + ...(query.limit === undefined ? {} : { limit: query.limit }), + ...(query.offset === undefined ? {} : { offset: query.offset }), + ...(query.actorId === undefined ? {} : { actorId: query.actorId }), + ...(query.action === undefined ? {} : { action: query.action }), + ...(query.resourceType === undefined ? {} : { resourceType: query.resourceType }), + ...(query.resourceOwner === undefined ? {} : { resourceOwner: query.resourceOwner }), +}); + export const AdminUsersHandlers = HttpApiBuilder.group( AdminUsersHttpApi, "adminUsers", (handlers) => handlers + .handle("listAuditEvents", ({ query }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AdminUsersProvider).listAuditEvents( + headers, + auditListOptions(query), + ); + }), + ) .handle("listUsers", ({ query }) => Effect.gen(function* () { const headers = yield* requestHeaders; diff --git a/packages/core/api/src/admin/reads.ts b/packages/core/api/src/admin/reads.ts index 96b6f2bbdc..36ad52ff46 100644 --- a/packages/core/api/src/admin/reads.ts +++ b/packages/core/api/src/admin/reads.ts @@ -16,6 +16,7 @@ import { Effect } from "effect"; import type { AdminConnection, + AdminListAuditEventsOptions, AdminSubject, AdminSubjectWithConnections, Executor, @@ -26,6 +27,7 @@ import { AdminUserNotFound, AdminUsersError, type AdminUserConnectionsResponse, + type AdminAuditEventsResponse, type AdminUserResponse, type AdminUsersResponse, type AdminUsersWithConnectionsResponse, @@ -145,6 +147,41 @@ const resolveIdentities = ( const ABSENT_IDENTITY: AdminUserIdentity = { email: null, displayName: null }; +export const listAuditEvents = ( + admin: ExecutorAdmin, + options: AdminListAuditEventsOptions, + directory?: AdminIdentityDirectory | AdminUserDirectory, +): Effect.Effect => + Effect.gen(function* () { + const events = yield* admin + .listAuditEvents(options) + .pipe(Effect.mapError(readFailed("audit events"))); + const actorIds = [ + ...new Set(events.flatMap((event) => (event.actorId === null ? [] : [event.actorId]))), + ]; + const identities = yield* resolveIdentities(asDirectory(directory).identities, actorIds); + return { + events: events.map((event) => { + const identity = + event.actorId === null + ? ABSENT_IDENTITY + : (identities.get(event.actorId) ?? ABSENT_IDENTITY); + return { + id: event.id, + actorId: event.actorId, + actorEmail: identity.email, + actorDisplayName: identity.displayName, + action: event.action, + resourceType: event.resourceType, + resourceOwner: event.resourceOwner, + resourceParent: event.resourceParent, + resourceId: event.resourceId, + createdAt: event.createdAt.getTime(), + }; + }), + }; + }); + /** * `AdminSubject` → the public `AdminUser` shape. * diff --git a/packages/core/api/src/admin/service.ts b/packages/core/api/src/admin/service.ts index d314e12d3b..11ec8c37d6 100644 --- a/packages/core/api/src/admin/service.ts +++ b/packages/core/api/src/admin/service.ts @@ -17,12 +17,15 @@ // --------------------------------------------------------------------------- import { Context, type Effect } from "effect"; +import type { AdminListAuditEventsOptions } from "@executor-js/sdk"; + import { type AdminUserNotFound, type AdminUsersError, type AdminUsersForbidden, type AdminUsersUnauthorized, AdminUserResponse, + AdminAuditEventsResponse, AdminUsersResponse, AdminUserConnectionsResponse, AdminUsersWithConnectionsResponse, @@ -40,6 +43,7 @@ export interface AdminUsersListOptions { } type User = typeof AdminUserResponse.Type; +type AuditEvents = typeof AdminAuditEventsResponse.Type; type Users = typeof AdminUsersResponse.Type; type UserConnections = typeof AdminUserConnectionsResponse.Type; type UsersWithConnections = typeof AdminUsersWithConnectionsResponse.Type; @@ -51,6 +55,10 @@ type Authorized = Effect.Effect< >; export interface AdminUsersProviderShape { + readonly listAuditEvents: ( + headers: AdminUsersHeaders, + options: AdminListAuditEventsOptions, + ) => Authorized; readonly listUsers: ( headers: AdminUsersHeaders, options: AdminUsersListOptions, diff --git a/packages/core/api/src/client.ts b/packages/core/api/src/client.ts index a46ec456fe..4e5e51d96f 100644 --- a/packages/core/api/src/client.ts +++ b/packages/core/api/src/client.ts @@ -20,6 +20,8 @@ export { AdminUsersError, AdminUsersForbidden, AdminUsersUnauthorized, + AdminAuditEvent, + AdminAuditEventsResponse, AdminUser, AdminUserConnection, AdminUserWithConnections, diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 2b7f8ea2e1..1e8ca3efe5 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -72,6 +72,8 @@ export { AdminUsersForbidden, AdminUsersUnauthorized, AdminUserNotFound, + AdminAuditEvent, + AdminAuditEventsResponse, AdminUser, AdminUserConnection, AdminUserWithConnections, diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 104d841c39..0ee1a844b6 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -33,6 +33,7 @@ export { export { AdminUsersHandlers } from "./admin/handlers"; export { platformViewOf, + listAuditEvents as listAdminAuditEvents, listUsers as listAdminUsers, listUsersWithConnections as listAdminUsersWithConnections, listUserConnections as listAdminUserConnections, diff --git a/packages/core/fumadb/src/adapters/drizzle/generate.ts b/packages/core/fumadb/src/adapters/drizzle/generate.ts index 6b19335899..e7e230442d 100644 --- a/packages/core/fumadb/src/adapters/drizzle/generate.ts +++ b/packages/core/fumadb/src/adapters/drizzle/generate.ts @@ -118,6 +118,11 @@ export function generateSchema( } return { name: "text" }; case "string": + if (provider === "mysql" && column instanceof IdColumn) { + throw new Error( + `Cannot generate MySQL schema for unbounded string primary key ${column.table.ormName}.${column.ormName}; declare an explicit varchar(n) id instead.`, + ); + } return { name: "text" }; case "binary": return { diff --git a/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts b/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts index 212a7258fb..185b18bb58 100644 --- a/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts +++ b/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts @@ -181,13 +181,19 @@ export async function introspectSchema( let col: AnyColumn; if (isPrimaryKey) { - if (!columnType.startsWith("varchar") && columnType !== "uuid") + if ( + !columnType.startsWith("varchar") && + columnType !== "string" && + columnType !== "uuid" + ) throw new Error( - `ID column only supports varchar and uuid at the moment, found ${columnType}.` + `ID column only supports string, varchar, and uuid at the moment, found ${columnType}.` ); if (columnType === "uuid") { col = idColumn(dbColumn.name, "uuid"); + } else if (columnType === "string") { + col = idColumn(dbColumn.name, "string"); } else { col = idColumn(dbColumn.name, columnType as `varchar(${number})`); } diff --git a/packages/core/fumadb/src/schema/create.ts b/packages/core/fumadb/src/schema/create.ts index 98dff52867..fc2f5aab06 100644 --- a/packages/core/fumadb/src/schema/create.ts +++ b/packages/core/fumadb/src/schema/create.ts @@ -344,7 +344,15 @@ type DefaultFunction = | (Type extends keyof DefaultFunctionMap ? DefaultFunctionMap[Type] : never) | (() => TypeMap[Type]); -type IdColumnType = `varchar(${number})` | "uuid"; +/** + * Storage types accepted by {@link idColumn}. + * + * MySQL schema generation requires a bounded `varchar(n)` or `uuid` primary + * key because an unbounded `string` maps to `text`, which MySQL cannot index as + * a primary key without a prefix length. PostgreSQL and SQLite may continue to + * use `string` ids. + */ +export type IdColumnType = `varchar(${number})` | "string" | "uuid"; export type TypeMap = { string: string; diff --git a/packages/core/fumadb/test/uuid.test.ts b/packages/core/fumadb/test/uuid.test.ts index 48bce926e3..e7cb154218 100644 --- a/packages/core/fumadb/test/uuid.test.ts +++ b/packages/core/fumadb/test/uuid.test.ts @@ -10,6 +10,12 @@ test("idColumn accepts uuid type", () => { expect(col.id).toBe(true); }); +test("idColumn accepts unbounded string type", () => { + const col = idColumn("id", "string").defaultTo$("auto"); + expect(col.type).toBe("string"); + expect(col.id).toBe(true); +}); + test("column accepts uuid type", () => { const col = column("token", "uuid"); expect(col.type).toBe("uuid"); @@ -90,6 +96,36 @@ test("Drizzle SQLite generates UUID schema correctly", () => { expect(generated).toContain("primaryKey()"); }); +test("Drizzle PostgreSQL generates a text primary id correctly", () => { + const stringIdSchema = schema({ + version: "1.0.0", + tables: { + audit: table("audit", { + id: idColumn("id", "string").defaultTo$("auto"), + }), + }, + }); + + const generated = Drizzle.generateSchema(stringIdSchema, "postgresql"); + expect(generated).toContain('text("id")'); + expect(generated).toContain("primaryKey()"); +}); + +test("Drizzle MySQL rejects an unbounded string primary id", () => { + const stringIdSchema = schema({ + version: "1.0.0", + tables: { + audit: table("audit", { + id: idColumn("id", "string").defaultTo$("auto"), + }), + }, + }); + + expect(() => Drizzle.generateSchema(stringIdSchema, "mysql")).toThrowError( + "Cannot generate MySQL schema for unbounded string primary key audit.id; declare an explicit varchar(n) id instead.", + ); +}); + test("TypeORM generates UUID schema correctly", () => { const generated = TypeORM.generateSchema(uuidSchema, "postgresql"); diff --git a/packages/core/sdk/src/audit-events.test.ts b/packages/core/sdk/src/audit-events.test.ts new file mode 100644 index 0000000000..5c58578f66 --- /dev/null +++ b/packages/core/sdk/src/audit-events.test.ts @@ -0,0 +1,754 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Result } from "effect"; + +import { createExecutor, type ExecutorAdmin } from "./executor"; +import { StorageError, type FumaDb } from "./fuma-runtime"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ProviderItemId, + ProviderKey, + ToolName, + Tenant, +} from "./ids"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestConfig } from "./testing"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; +import { firstPartyOAuthClientSlug, type FirstPartyOAuthClientConfig } from "./oauth-client"; + +const INTEGRATION = IntegrationSlug.make("example"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const memoryProvider = ( + failWrites: () => boolean = () => false, + failDeletes: () => boolean = () => false, +): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => + failWrites() + ? Effect.fail( + new StorageError({ message: "credential provider write refused", cause: undefined }), + ) + : Effect.sync(() => void store.set(String(id), value)), + delete: (id) => + failDeletes() + ? Effect.fail( + new StorageError({ message: "credential provider delete refused", cause: undefined }), + ) + : Effect.sync(() => void store.delete(String(id))), + has: (id) => Effect.sync(() => store.has(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((key) => ({ + id: ProviderItemId.make(key), + name: key, + })), + ), + }; +}; + +const racingProvider = () => { + const store = new Map(); + let failWrites = false; + const provider: CredentialProvider = { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => + failWrites + ? Effect.sync(() => void store.set(String(id), "partial-stale-write")).pipe( + Effect.andThen( + Effect.fail( + new StorageError({ + message: "credential provider write refused", + cause: undefined, + }), + ), + ), + ) + : Effect.sync(() => void store.set(String(id), value)), + delete: (id) => Effect.sync(() => void store.delete(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((key) => ({ + id: ProviderItemId.make(key), + name: key, + })), + ), + }; + return { + provider, + armFailure: () => { + failWrites = true; + }, + installSuccessorSecret: () => { + for (const key of store.keys()) store.set(key, "successor-secret"); + }, + values: () => [...store.values()], + }; +}; + +const makeAuditPlugin = (provider: CredentialProvider) => + definePlugin(() => ({ + id: "audit-test" as const, + credentialProviders: [provider], + storage: () => ({}), + resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("run") }] }), + describeAuthMethods: () => [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: ["read"] }, + }, + ], + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEGRATION, + description: "Example", + config: {}, + }), + replace: () => + ctx.core.integrations.register({ + slug: INTEGRATION, + description: "Replacement", + config: { version: 2 }, + }), + }), + }))(); + +/** Fault-inject only audit inserts, including inside transaction handles. */ +const failAuditInserts = (db: FumaDb, armed: () => boolean): FumaDb => { + const wrap = (inner: FumaDb): FumaDb => + new Proxy(inner, { + get(target, property) { + if (property === "withContext") { + return (context: unknown) => + wrap((target.withContext as (value: unknown) => FumaDb)(context)); + } + if (property === "transaction") { + return (run: (transactionDb: FumaDb) => Promise) => + target.transaction((transactionDb) => run(wrap(transactionDb as FumaDb))); + } + if (property === "create") { + return (table: unknown, values: unknown) => + armed() && table === "audit_event" + ? // oxlint-disable-next-line executor/no-promise-reject -- boundary: fault-injecting raw FumaDB adapter simulates a rejected audit insert + Promise.reject( + new StorageError({ message: "audit insert refused", cause: undefined }), + ) + : (target.create as (name: unknown, input: unknown) => Promise)( + table, + values, + ); + } + return Reflect.get(target, property); + }, + }); + return wrap(db); +}; + +/** Replace the compensated OAuth row immediately before its provider-ownership + * recheck, modeling a concurrent successor that committed in that interval. */ +const replaceOAuthClientBeforeCompensationRecheck = ( + db: FumaDb, + armed: () => boolean, + installSuccessorSecret: () => void, +): FumaDb => { + let transactionCount = 0; + const wrap = (inner: FumaDb): FumaDb => + new Proxy(inner, { + get(target, property) { + if (property === "withContext") { + return (context: unknown) => + wrap((target.withContext as (value: unknown) => FumaDb)(context)); + } + if (property === "transaction") { + return (run: (transactionDb: FumaDb) => Promise) => { + if (armed()) transactionCount += 1; + return target.transaction(async (transactionDb) => { + if (armed() && transactionCount === 3) { + const current = await transactionDb.findFirst("oauth_client", {}); + if (current !== null) { + const { row_id: _rowId, ...values } = current as Record; + await transactionDb.deleteMany("oauth_client", {}); + await transactionDb.create("oauth_client", { + ...values, + client_id: "successor-client-id", + }); + installSuccessorSecret(); + } + } + return run(wrap(transactionDb as FumaDb)); + }); + }; + } + return Reflect.get(target, property); + }, + }); + return wrap(db); +}; + +const requireAdmin = (admin: ExecutorAdmin | undefined) => + admin === undefined ? Effect.die("expected a platform admin view") : Effect.succeed(admin); + +const setup = ( + failAudit?: () => boolean, + provider: CredentialProvider = memoryProvider(), + firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[], +) => + Effect.gen(function* () { + const auditPlugin = makeAuditPlugin(provider); + const config = makeTestConfig({ + tenant: "audit-tenant", + subject: "actor-123", + plugins: [auditPlugin] as const, + firstPartyOAuthClients, + }); + const executor = yield* createExecutor({ + ...config, + db: failAudit ? failAuditInserts(config.db, failAudit) : config.db, + }); + const platformExecutor = yield* createExecutor({ + tenant: config.tenant, + db: config.testDb.db, + platformView: true, + onElicitation: "accept-all", + }); + const admin = yield* requireAdmin(platformExecutor.admin); + yield* Effect.addFinalizer(() => + executor + .close() + .pipe( + Effect.andThen(platformExecutor.close()), + Effect.andThen(Effect.promise(() => config.testDb.close())), + Effect.ignore, + ), + ); + return { executor, admin, db: config.testDb.db }; + }); + +describe("admin audit events", () => { + it.effect("records successful lifecycle changes with actor, scope, and safe identifiers", () => + Effect.gen(function* () { + const { executor, admin } = yield* setup(); + yield* executor["audit-test"].seed(); + + const shared = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEGRATION, + template: TEMPLATE, + value: "SECRET-workspace-token", + }); + const personal = yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: INTEGRATION, + template: TEMPLATE, + value: "SECRET-personal-token", + }); + yield* executor.connections.update( + { owner: shared.owner, integration: shared.integration, name: shared.name }, + { description: "renamed" }, + ); + yield* executor.connections.remove({ + owner: personal.owner, + integration: personal.integration, + name: personal.name, + }); + + const client = OAuthClientSlug.make("workspace-app"); + const clientInput = { + owner: "org" as const, + slug: client, + authorizationUrl: "https://example.test/authorize", + tokenUrl: "https://example.test/token", + grant: "authorization_code" as const, + clientId: "client-id", + clientSecret: "SECRET-client-secret", + }; + yield* executor.oauth.createClient(clientInput); + yield* executor.oauth.createClient({ ...clientInput, clientId: "updated-client-id" }); + yield* executor.oauth.removeClient("org", client); + + yield* executor["audit-test"].replace(); + yield* executor.integrations.update(INTEGRATION, { name: "Renamed" }); + yield* executor.integrations.healthCheck.set(INTEGRATION, { + operation: "run", + }); + const policy = yield* executor.policies.create({ + owner: "org", + pattern: "*", + action: "require_approval", + }); + yield* executor.policies.update({ + id: policy.id, + owner: "org", + action: "block", + }); + yield* executor.policies.remove({ id: policy.id, owner: "org" }); + yield* executor.integrations.remove(INTEGRATION); + + const events = yield* admin.listAuditEvents(); + expect(events).toHaveLength(15); + expect(new Set(events.map((event) => event.actorId))).toEqual(new Set(["actor-123"])); + expect( + events.map(({ action, resourceType, resourceOwner, resourceParent, resourceId }) => ({ + action, + resourceType, + resourceOwner, + resourceParent, + resourceId, + })), + ).toEqual( + expect.arrayContaining([ + { + action: "created", + resourceType: "connection", + resourceOwner: "org", + resourceParent: "example", + resourceId: "shared", + }, + { + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceParent: "example", + resourceId: "personal", + }, + { + action: "updated", + resourceType: "oauth_client", + resourceOwner: "org", + resourceParent: null, + resourceId: "workspace-app", + }, + { + action: "removed", + resourceType: "integration", + resourceOwner: null, + resourceParent: null, + resourceId: "example", + }, + { + action: "updated", + resourceType: "integration", + resourceOwner: null, + resourceParent: null, + resourceId: "example", + }, + { + action: "created", + resourceType: "tool_policy", + resourceOwner: "org", + resourceParent: null, + resourceId: String(policy.id), + }, + { + action: "updated", + resourceType: "tool_policy", + resourceOwner: "org", + resourceParent: null, + resourceId: String(policy.id), + }, + { + action: "removed", + resourceType: "tool_policy", + resourceOwner: "org", + resourceParent: null, + resourceId: String(policy.id), + }, + ]), + ); + expect( + events.filter( + (event) => + event.action === "updated" && + event.resourceType === "integration" && + event.resourceId === "example", + ), + ).toHaveLength(3); + + const serialized = JSON.stringify(events); + expect(serialized).not.toContain("SECRET-"); + expect(serialized).not.toContain("client-id"); + expect(serialized).not.toContain("authorizationUrl"); + }).pipe(Effect.scoped), + ); + + it.effect("filters, pages, and isolates the tenant", () => + Effect.gen(function* () { + const { executor, admin, db } = yield* setup(); + yield* executor["audit-test"].seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEGRATION, + template: TEMPLATE, + value: "workspace-token", + }); + yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: INTEGRATION, + template: TEMPLATE, + value: "personal-token", + }); + + const orgEvents = yield* admin.listAuditEvents({ resourceOwner: "org" }); + expect(orgEvents).toHaveLength(1); + expect(orgEvents[0]).toMatchObject({ resourceType: "connection", resourceId: "shared" }); + expect(yield* admin.listAuditEvents({ resourceType: "connection", limit: 1 })).toHaveLength( + 1, + ); + expect(yield* admin.listAuditEvents({ resourceType: "connection", offset: 1 })).toHaveLength( + 1, + ); + + const otherPlatform = yield* createExecutor({ + tenant: Tenant.make("other-tenant"), + db, + platformView: true, + onElicitation: "accept-all", + }); + yield* Effect.addFinalizer(() => otherPlatform.close().pipe(Effect.ignore)); + const otherAdmin = yield* requireAdmin(otherPlatform.admin); + expect(yield* otherAdmin.listAuditEvents()).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect("leaves credentials untouched when the audit insert rolls back", () => + Effect.gen(function* () { + let armed = false; + const { executor, admin } = yield* setup(() => armed); + yield* executor["audit-test"].seed(); + const beforeItems = yield* executor.providers.items(ProviderKey.make("memory")); + const beforeEvents = yield* admin.listAuditEvents(); + armed = true; + + const connectionResult = yield* executor.connections + .create({ + owner: "org", + name: ConnectionName.make("audit-failure"), + integration: INTEGRATION, + template: TEMPLATE, + value: "SECRET-must-not-stick", + }) + .pipe(Effect.result); + expect(Result.isFailure(connectionResult)).toBe(true); + expect(yield* executor.connections.list()).toEqual([]); + expect(yield* executor.providers.items(ProviderKey.make("memory"))).toEqual(beforeItems); + + const oauthResult = yield* executor.oauth + .createClient({ + owner: "org", + slug: OAuthClientSlug.make("audit-failure-app"), + authorizationUrl: "https://example.test/authorize", + tokenUrl: "https://example.test/token", + grant: "authorization_code", + clientId: "client-id", + clientSecret: "SECRET-must-not-stick-either", + }) + .pipe(Effect.result); + expect(Result.isFailure(oauthResult)).toBe(true); + expect(yield* executor.oauth.listClients()).toEqual([]); + expect(yield* executor.providers.items(ProviderKey.make("memory"))).toEqual(beforeItems); + expect(yield* admin.listAuditEvents()).toEqual(beforeEvents); + }).pipe(Effect.scoped), + ); + + it.effect("records rollback events when credential persistence fails after commit", () => + Effect.gen(function* () { + let failWrites = false; + const { executor, admin } = yield* setup( + undefined, + memoryProvider(() => failWrites), + ); + yield* executor["audit-test"].seed(); + failWrites = true; + + const connectionResult = yield* executor.connections + .create({ + owner: "org", + name: ConnectionName.make("provider-failure"), + integration: INTEGRATION, + template: TEMPLATE, + value: "SECRET-must-not-stick", + }) + .pipe(Effect.result); + expect(Result.isFailure(connectionResult)).toBe(true); + expect(yield* executor.connections.list()).toEqual([]); + + const oauthResult = yield* executor.oauth + .createClient({ + owner: "org", + slug: OAuthClientSlug.make("provider-failure-app"), + authorizationUrl: "https://example.test/authorize", + tokenUrl: "https://example.test/token", + grant: "authorization_code", + clientId: "client-id", + clientSecret: "SECRET-must-not-stick-either", + }) + .pipe(Effect.result); + expect(Result.isFailure(oauthResult)).toBe(true); + expect(yield* executor.oauth.listClients()).toEqual([]); + expect(yield* executor.providers.items(ProviderKey.make("memory"))).toEqual([]); + + const events = yield* admin.listAuditEvents(); + const connectionActions = events + .filter((event) => event.resourceId === "providerFailure") + .map((event) => event.action); + expect(connectionActions).toHaveLength(2); + expect(connectionActions).toEqual(expect.arrayContaining(["created", "rolled_back"])); + const clientActions = events + .filter((event) => event.resourceId === "provider-failure-app") + .map((event) => event.action); + expect(clientActions).toHaveLength(2); + expect(clientActions).toEqual(expect.arrayContaining(["created", "rolled_back"])); + }).pipe(Effect.scoped), + ); + + it.effect("commits and audits an OAuth connection before persisting its tokens", () => + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + let failWrites = false; + const firstPartyClient: FirstPartyOAuthClientConfig = { + name: "audit-order", + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + integrations: [INTEGRATION], + }; + const { executor, admin } = yield* setup( + undefined, + memoryProvider(() => failWrites), + [firstPartyClient], + ); + yield* executor["audit-test"].seed(); + const started = yield* executor.oauth.start({ + owner: "org", + client: firstPartyOAuthClientSlug(firstPartyClient.name), + clientOwner: "org", + name: ConnectionName.make("oauth-order"), + integration: INTEGRATION, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + failWrites = true; + + const result = yield* executor.oauth + .complete({ state: started.state, code: callback.code }) + .pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + expect(yield* executor.connections.list()).toEqual([]); + expect(yield* executor.providers.items(ProviderKey.make("memory"))).toEqual([]); + const connectionActions = (yield* admin.listAuditEvents()) + .filter((event) => event.resourceId === "oauthOrder") + .map((event) => event.action); + expect(connectionActions).toHaveLength(2); + expect(connectionActions).toEqual(expect.arrayContaining(["created", "rolled_back"])); + }).pipe(Effect.scoped), + ); + + it.effect("records rollback failure when provider credential restoration fails", () => + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + let failProviderWrites = false; + let failProviderDeletes = false; + const firstPartyClient: FirstPartyOAuthClientConfig = { + name: "rollback-failure", + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + integrations: [INTEGRATION], + }; + const { executor, admin } = yield* setup( + undefined, + memoryProvider( + () => failProviderWrites, + () => failProviderDeletes, + ), + [firstPartyClient], + ); + yield* executor["audit-test"].seed(); + + const clientSlug = OAuthClientSlug.make("rollback-failure-app"); + failProviderWrites = true; + failProviderDeletes = true; + const clientResult = yield* executor.oauth + .createClient({ + owner: "org", + slug: clientSlug, + authorizationUrl: "https://example.test/authorize", + tokenUrl: "https://example.test/token", + grant: "authorization_code", + clientId: "client-id", + clientSecret: "SECRET-will-fail", + }) + .pipe(Effect.result); + expect(Result.isFailure(clientResult)).toBe(true); + + failProviderWrites = false; + failProviderDeletes = false; + const started = yield* executor.oauth.start({ + owner: "org", + client: firstPartyOAuthClientSlug(firstPartyClient.name), + clientOwner: "org", + name: ConnectionName.make("rollback-failure"), + integration: INTEGRATION, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + failProviderWrites = true; + failProviderDeletes = true; + const connectionResult = yield* executor.oauth + .complete({ state: started.state, code: callback.code }) + .pipe(Effect.result); + expect(Result.isFailure(connectionResult)).toBe(true); + + const events = yield* admin.listAuditEvents(); + expect( + events + .filter((event) => event.resourceId === String(clientSlug)) + .map((event) => event.action), + ).toEqual(expect.arrayContaining(["created", "rolled_back", "rollback_failed"])); + expect( + events + .filter((event) => event.resourceId === "rollbackFailure") + .map((event) => event.action), + ).toEqual(expect.arrayContaining(["created", "rolled_back", "rollback_failed"])); + }).pipe(Effect.scoped), + ); + + it.effect("attempts later credential restores after one fails and audits the failure", () => + Effect.gen(function* () { + const store = new Map(); + const deleteAttempts: string[] = []; + let armed = false; + const provider: CredentialProvider = { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => + armed && String(id).endsWith(":third") + ? Effect.fail(new StorageError({ message: "third write refused", cause: undefined })) + : Effect.sync(() => void store.set(String(id), value)), + delete: (id) => + Effect.sync(() => { + const itemId = String(id); + deleteAttempts.push(itemId); + if (!itemId.endsWith(":first")) store.delete(itemId); + }).pipe( + Effect.andThen( + String(id).endsWith(":first") + ? Effect.fail( + new StorageError({ message: "first restore refused", cause: undefined }), + ) + : Effect.void, + ), + ), + }; + const { executor, admin } = yield* setup(undefined, provider); + yield* executor["audit-test"].seed(); + armed = true; + + const result = yield* executor.connections + .create({ + owner: "org", + name: ConnectionName.make("attempt-all-restores"), + integration: INTEGRATION, + template: TEMPLATE, + values: { first: "one", second: "two", third: "three" }, + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + expect(deleteAttempts).toHaveLength(2); + expect(deleteAttempts[0]).toMatch(/:first$/); + expect(deleteAttempts[1]).toMatch(/:second$/); + expect([...store.keys()]).toHaveLength(1); + expect([...store.keys()][0]).toMatch(/:first$/); + const actions = (yield* admin.listAuditEvents()) + .filter((event) => event.resourceId === "attemptAllRestores") + .map((event) => event.action); + expect(actions).toEqual( + expect.arrayContaining(["created", "rolled_back", "rollback_failed"]), + ); + }).pipe(Effect.scoped), + ); + + it.effect("does not overwrite a concurrent OAuth-client successor during compensation", () => + Effect.gen(function* () { + const controlled = racingProvider(); + let armed = false; + const auditPlugin = makeAuditPlugin(controlled.provider); + const config = makeTestConfig({ + tenant: "audit-tenant", + subject: "actor-123", + plugins: [auditPlugin] as const, + }); + const executor = yield* createExecutor({ + ...config, + db: replaceOAuthClientBeforeCompensationRecheck( + config.db, + () => armed, + controlled.installSuccessorSecret, + ), + }); + yield* Effect.addFinalizer(() => + executor + .close() + .pipe(Effect.andThen(Effect.promise(() => config.testDb.close())), Effect.ignore), + ); + const slug = OAuthClientSlug.make("racing-app"); + const base = { + owner: "org" as const, + slug, + authorizationUrl: "https://example.test/authorize", + tokenUrl: "https://example.test/token", + grant: "authorization_code" as const, + }; + yield* executor.oauth.createClient({ + ...base, + clientId: "original-client-id", + clientSecret: "original-secret", + }); + controlled.armFailure(); + armed = true; + + const result = yield* executor.oauth + .createClient({ + ...base, + clientId: "stale-client-id", + clientSecret: "stale-secret", + }) + .pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + expect(yield* executor.oauth.listClients()).toEqual([ + expect.objectContaining({ slug, clientId: "successor-client-id" }), + ]); + expect(controlled.values()).not.toContain("partial-stale-write"); + expect(controlled.values()).toEqual(expect.arrayContaining(["successor-secret"])); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/core/sdk/src/audit.ts b/packages/core/sdk/src/audit.ts new file mode 100644 index 0000000000..c3b14e1395 --- /dev/null +++ b/packages/core/sdk/src/audit.ts @@ -0,0 +1,51 @@ +import type { Owner } from "./ids"; + +export const AUDIT_EVENT_ACTIONS = [ + "created", + "updated", + "removed", + "rolled_back", + "rollback_failed", +] as const; +export type AuditEventAction = (typeof AUDIT_EVENT_ACTIONS)[number]; + +export const AUDIT_RESOURCE_TYPES = [ + "connection", + "integration", + "oauth_client", + "tool_policy", +] as const; +export type AuditResourceType = (typeof AUDIT_RESOURCE_TYPES)[number]; + +/** A durable, tenant-scoped record of a user-intent configuration mutation. + * Credential values and provider item ids are deliberately never recorded. */ +export interface AdminAuditEvent { + readonly id: string; + readonly actorId: string | null; + readonly action: AuditEventAction; + readonly resourceType: AuditResourceType; + readonly resourceOwner: Owner | null; + /** Parent namespace for a resource. Connections use their integration slug. */ + readonly resourceParent: string | null; + /** The resource's own stable identifier (connection name, integration slug, + * OAuth-client slug, or tool-policy id). */ + readonly resourceId: string; + readonly createdAt: Date; +} + +export interface AdminListAuditEventsOptions { + readonly limit?: number; + readonly offset?: number; + readonly actorId?: string; + readonly action?: AuditEventAction; + readonly resourceType?: AuditResourceType; + readonly resourceOwner?: Owner; +} + +export interface AuditEventInput { + readonly action: AuditEventAction; + readonly resourceType: AuditResourceType; + readonly resourceOwner?: Owner | null; + readonly resourceParent?: string | null; + readonly resourceId: string; +} diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8014584695..12815d4d54 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -57,11 +57,12 @@ const tenantExecutorTable = ( name: string, columns: TColumns, uniqueKey: readonly string[], + keyStorage: "varchar(255)" | "string" = "varchar(255)", ) => { const out = table(name, { ...columns, - row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"), - tenant: keyColumn("tenant"), + row_id: idColumn("row_id", keyStorage).defaultTo$("auto"), + tenant: column("tenant", keyStorage), }); out.unique(`${name}_uidx`, [...uniqueKey]); return out.policy({ @@ -197,6 +198,31 @@ export const coreTables = defineTables({ ["tenant", "external_id"], ), + // Append-only configuration audit history. Tenant-scoped so the platform + // view can read every actor's events without widening any credential-bearing + // owner-scoped table. Rows contain identifiers only — never credential + // values, provider item ids, OAuth tokens, or free-form descriptions. + audit_event: tenantExecutorTable( + "audit_event", + { + // Internal audit ids are generated locally with a short fixed prefix and + // random suffix. Keep their bound explicit so every SQL generator can + // represent the primary/index contract without silently narrowing it. + id: keyColumn("id"), + actor_id: nullableTextColumn("actor_id"), + action: textColumn("action"), + resource_type: textColumn("resource_type"), + resource_owner: nullableTextColumn("resource_owner"), + resource_parent: nullableTextColumn("resource_parent"), + resource_id: textColumn("resource_id"), + created_at: dateColumn("created_at"), + }, + // The unique index doubles as the newest-first admin read index. `id` is + // globally unique in practice and remains the final tie-breaker for events + // written in the same millisecond. + ["tenant", "created_at", "id"], + ), + // 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 @@ -444,6 +470,7 @@ export type CoreSchema = typeof coreTables; export type IntegrationRow = FumaRow; export type SubjectRow = FumaRow; +export type AuditEventRow = FumaRow; export type ConnectionRow = FumaRow; export type OAuthClientRow = FumaRow; export type OAuthSessionRow = FumaRow; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 6916233cb5..8c3f227601 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -59,11 +59,19 @@ import { type ConnectionRow, type CoreSchema, type IntegrationRow, + type AuditEventRow, type OAuthClientRow, type ToolInvocationRow, type ToolRow, type ToolPolicyRow, } from "./core-schema"; +import type { + AdminAuditEvent, + AdminListAuditEventsOptions, + AuditEventInput, + AuditEventAction, + AuditResourceType, +} from "./audit"; import { ElicitationDeclinedError, ElicitationResponse, @@ -636,6 +644,11 @@ const normalizeAdminPaging = ( }; export interface ExecutorAdmin { + /** Newest-first tenant audit history. Identifiers only: no credential + * material or free-form configuration is exposed. */ + readonly listAuditEvents: ( + options?: AdminListAuditEventsOptions, + ) => Effect.Effect; /** One page of subjects under the tenant, oldest first (stable: ties break on * `external_id`). ALWAYS bounded: no arguments means * {@link ADMIN_DEFAULT_PAGE_SIZE} rows from offset 0, and `limit` is clamped @@ -1989,6 +2002,24 @@ export const createExecutor = (effect: Effect.Effect) => fuma.transaction(effect); + const recordAuditEvent = (input: AuditEventInput): Effect.Effect => { + const createdAt = new Date(); + const id = `aud_${createdAt.getTime().toString(36)}_${Math.random().toString(36).slice(2, 12)}`; + return core + .create("audit_event", { + tenant, + id, + actor_id: subject, + action: input.action, + resource_type: input.resourceType, + resource_owner: input.resourceOwner ?? null, + resource_parent: input.resourceParent ?? null, + resource_id: input.resourceId, + created_at: createdAt, + }) + .pipe(Effect.asVoid); + }; + // Runtime-observed output shapes ("muscle memory"): learned on the // execute success path, served by tools.schema when a tool declares no // output schema. Backed by plugin_storage under a reserved system id. @@ -3237,6 +3268,11 @@ export const createExecutor = b("slug", "=", String(slug)), set, }); + yield* recordAuditEvent({ + action: "updated", + resourceType: "integration", + resourceId: String(slug), + }); }), ); @@ -3345,6 +3391,11 @@ export const createExecutor = b("slug", "=", String(slug)), }); + yield* recordAuditEvent({ + action: "removed", + resourceType: "integration", + resourceId: String(slug), + }); return existing.plugin_id; }), ).pipe( @@ -3420,6 +3471,11 @@ export const createExecutor = b("slug", "=", String(slug)), set: { health_check: spec, updated_at: new Date() }, }); + yield* recordAuditEvent({ + action: "updated", + resourceType: "integration", + resourceId: String(slug), + }); }), ); @@ -3992,6 +4048,13 @@ export const createExecutor = b.and(byOwner(input.owner)(b), b("id", "=", input.id)); + const existing = yield* core.findFirst("tool_policy", { where }); yield* core.deleteMany("tool_policy", { where }); + if (existing) { + yield* recordAuditEvent({ + action: "removed", + resourceType: "tool_policy", + resourceOwner: input.owner, + resourceId: input.id, + }); + } }), ); @@ -6602,6 +6735,7 @@ export const createExecutor = ownedKeys(owner), guardOrgWrite: (owner: Owner) => guardOrgWrite(owner), + recordAuditEvent, defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), connectionNameTaken: (ref) => findConnectionRow(ref).pipe(Effect.map((row) => row !== null)), @@ -6872,6 +7006,44 @@ export const createExecutor = ({ + id: row.id, + actorId: row.actor_id == null ? null : String(row.actor_id), + action: row.action as AuditEventAction, + resourceType: row.resource_type as AuditResourceType, + resourceOwner: row.resource_owner == null ? null : (row.resource_owner as Owner), + resourceParent: row.resource_parent == null ? null : String(row.resource_parent), + resourceId: String(row.resource_id), + createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + }); + + const listAuditEvents = ( + options?: AdminListAuditEventsOptions, + ): Effect.Effect => { + const { limit, offset } = normalizeAdminPaging(options); + return platformCore + .findMany("audit_event", { + where: (b: AnyCb) => + b.and( + options?.actorId === undefined ? true : b("actor_id", "=", options.actorId), + options?.action === undefined ? true : b("action", "=", options.action), + options?.resourceType === undefined + ? true + : b("resource_type", "=", options.resourceType), + options?.resourceOwner === undefined + ? true + : b("resource_owner", "=", options.resourceOwner), + ), + orderBy: [ + ["created_at", "desc"], + ["id", "desc"], + ], + limit, + offset, + }) + .pipe(Effect.map((rows) => rows.map(rowToAdminAuditEvent))); + }; + const listSubjects = ( options?: AdminListSubjectsOptions, ): Effect.Effect => { @@ -6985,6 +7157,7 @@ export const createExecutor = Effect.Effect; + readonly recordAuditEvent: (input: AuditEventInput) => Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ readonly mintOAuthConnection: ( @@ -910,7 +912,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const now = new Date(); // Resolve the out-of-band write up front, but do not mutate the provider - // until the database transaction commits. + // until the database transaction (including its audit row) commits. + // This is the same ordering as connection creation: a failed audit insert + // cannot leave an unaudited secret behind. let clientSecretItemIdValue: string | null = null; let credentialWrite: CredentialWriteAttempt | null = null; let secretWrite: CredentialWriteSnapshot | undefined; @@ -1000,6 +1004,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + yield* deps.recordAuditEvent({ + action: existing ? "updated" : "created", + resourceType: "oauth_client", + resourceOwner: input.owner, + resourceId: String(input.slug), + }); return { existing, rowId }; }), ); @@ -1037,6 +1047,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { looseDb(db).create("oauth_client", existing), ); } + yield* deps.recordAuditEvent({ + action: "rolled_back", + resourceType: "oauth_client", + resourceOwner: input.owner, + resourceId: String(input.slug), + }); return true; }), ) @@ -1088,6 +1104,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } if (Predicate.isTagged(restoredCredential, "Failed")) { + yield* deps.recordAuditEvent({ + action: "rollback_failed", + resourceType: "oauth_client", + resourceOwner: input.owner, + resourceId: String(input.slug), + }); return yield* new StorageError({ message: `Failed to store the OAuth client secret for ${input.owner}/${String(input.slug)}, and credential compensation also failed.`, cause: restoredCredential.cause, @@ -1161,6 +1183,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }), ) .pipe(Effect.asVoid); + if (existing) { + yield* deps.recordAuditEvent({ + action: "removed", + resourceType: "oauth_client", + resourceOwner: owner, + resourceId: String(slug), + }); + } return existing; }), ); @@ -2262,7 +2292,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- // Mint the connection from a freshly exchanged token: hand the access value - // (+ refresh) to the executor, which commits the row first, persists the + // (+ refresh) to the executor, which commits row + audit first, persists the // credentials with compensation, then produces the connection's tools. // ----------------------------------------------------------------------- const mintFromToken = ( diff --git a/packages/react/src/api/admin-atoms.tsx b/packages/react/src/api/admin-atoms.tsx index e72bac7812..753090dc2f 100644 --- a/packages/react/src/api/admin-atoms.tsx +++ b/packages/react/src/api/admin-atoms.tsx @@ -20,12 +20,26 @@ import { ReactivityKey } from "./reactivity-keys"; * 1..500 bound; the joined endpoint reads per-user connections, so a modest * page keeps that join cheap. */ export const ADMIN_USERS_PAGE_SIZE = 25; +export const ADMIN_AUDIT_EVENTS_PAGE_SIZE = 50; export interface AdminUsersPage { readonly limit: number; readonly offset: number; } +export interface AdminAuditEventsPage { + readonly limit: number; + readonly offset: number; +} + +export const adminAuditEventsAtom = Atom.family((page: AdminAuditEventsPage) => + AdminApiClient.query("adminUsers", "listAuditEvents", { + query: { limit: page.limit + 1, offset: page.offset }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.adminUsers], + }), +); + /** * One page of users joined with their connections — what the list renders. * diff --git a/packages/react/src/lib/admin-users-display.test.ts b/packages/react/src/lib/admin-users-display.test.ts index fad73a3396..f752fafb63 100644 --- a/packages/react/src/lib/admin-users-display.test.ts +++ b/packages/react/src/lib/admin-users-display.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "@effect/vitest"; import type { IntegrationSlug } from "@executor-js/sdk/shared"; import { + adminAuditActorLabel, + adminAuditResourceLabel, + adminAuditScopeLabel, + auditActionLabel, adminUserCopyableEmail, adminUserTitle, connectionHealthStatus, @@ -18,6 +22,53 @@ import { type AdminConnectionRow, } from "./admin-users-display"; +describe("audit activity display", () => { + it("distinguishes a completed rollback from a failed credential restore", () => { + expect(auditActionLabel("rolled_back")).toBe("Rolled back"); + expect(auditActionLabel("rollback_failed")).toBe("Rollback failed"); + }); + + it("names actors without inventing an identity for system events", () => { + expect( + adminAuditActorLabel({ + actorId: "user_1", + actorEmail: "admin@example.test", + actorDisplayName: "Admin", + }), + ).toBe("admin@example.test"); + expect(adminAuditActorLabel({ actorId: null, actorEmail: null, actorDisplayName: null })).toBe( + "System", + ); + }); + + it("renders safe resource identifiers and personal versus workspace scope", () => { + expect( + adminAuditResourceLabel({ + resourceType: "connection", + resourceParent: "github", + resourceId: "main", + }), + ).toBe("Connection: github / main"); + expect( + adminAuditResourceLabel({ + resourceType: "oauth_client", + resourceParent: null, + resourceId: "workspace-app", + }), + ).toBe("OAuth app: workspace-app"); + expect( + adminAuditResourceLabel({ + resourceType: "tool_policy", + resourceParent: "github", + resourceId: "policy-1", + }), + ).toBe("Tool policy: github / policy-1"); + expect(adminAuditScopeLabel("user")).toBe("Personal"); + expect(adminAuditScopeLabel("org")).toBe("Workspace"); + expect(adminAuditScopeLabel(null)).toBe("Workspace"); + }); +}); + const slug = (value: string): IntegrationSlug => value as IntegrationSlug; /** A catalog row for a normal, connectable integration. `kind` is the owning diff --git a/packages/react/src/lib/admin-users-display.ts b/packages/react/src/lib/admin-users-display.ts index 41ba535640..4a95bc160e 100644 --- a/packages/react/src/lib/admin-users-display.ts +++ b/packages/react/src/lib/admin-users-display.ts @@ -141,6 +141,26 @@ export const adminUserCopyableEmail = (user: AdminUserIdentityRow): string | nul // ── Connections ───────────────────────────────────────────────────────────── +/** Append-only action vocabulary shown in the admin activity feed. */ +export type AdminAuditAction = + | "created" + | "updated" + | "removed" + | "rolled_back" + | "rollback_failed"; + +/** Render an audit action without exposing its storage spelling. */ +const AUDIT_ACTION_LABELS = { + created: "Created", + updated: "Updated", + removed: "Removed", + rolled_back: "Rolled back", + rollback_failed: "Rollback failed", +} satisfies Record; + +/** Render an audit action without exposing its storage spelling. */ +export const auditActionLabel = (action: AdminAuditAction): string => AUDIT_ACTION_LABELS[action]; + /** The health status a row displays. A connection that was never probed carries * no verdict, which is `unknown` in the shared vocabulary. */ export const connectionHealthStatus = (connection: AdminConnectionRow): HealthStatus => @@ -263,6 +283,40 @@ export const connectLinkUrl = ( return org ? `${base}/${org}/connect/${integration}` : `${base}/connect/${integration}`; }; +// ── Audit activity ───────────────────────────────────────────────────────── + +export interface AdminAuditActorRow { + readonly actorId: string | null; + readonly actorEmail: string | null; + readonly actorDisplayName: string | null; +} + +/** Human-readable actor, with the stable id retained as the final fallback. */ +export const adminAuditActorLabel = (event: AdminAuditActorRow): string => + event.actorEmail ?? event.actorDisplayName ?? event.actorId ?? "System"; + +export const adminAuditResourceLabel = (event: { + readonly resourceType: "connection" | "integration" | "oauth_client" | "tool_policy"; + readonly resourceParent: string | null; + readonly resourceId: string; +}): string => { + const kind = + event.resourceType === "oauth_client" + ? "OAuth app" + : event.resourceType === "tool_policy" + ? "Tool policy" + : event.resourceType === "integration" + ? "Integration" + : "Connection"; + const identifier = event.resourceParent + ? `${event.resourceParent} / ${event.resourceId}` + : event.resourceId; + return `${kind}: ${identifier}`; +}; + +export const adminAuditScopeLabel = (owner: Owner | null): string => + owner === "user" ? "Personal" : "Workspace"; + // ── Paging ────────────────────────────────────────────────────────────────── /** diff --git a/packages/react/src/pages/admin-users.tsx b/packages/react/src/pages/admin-users.tsx index 49fae31d68..6cb10094dc 100644 --- a/packages/react/src/pages/admin-users.tsx +++ b/packages/react/src/pages/admin-users.tsx @@ -9,7 +9,9 @@ import type { HealthStatus, Integration, IntegrationSlug } from "@executor-js/sd import { useIntegrationPlugins } from "@executor-js/sdk/client"; import { + ADMIN_AUDIT_EVENTS_PAGE_SIZE, ADMIN_USERS_PAGE_SIZE, + adminAuditEventsAtom, adminUserConnectionsAtom, adminUsersWithConnectionsAtom, } from "../api/admin-atoms"; @@ -18,6 +20,7 @@ import { ownerLabel } from "../api/owner-display"; import { Button } from "../components/button"; import { CopyButton } from "../components/copy-button"; import { ErrorState } from "../components/error-state"; +import { FilterTabs } from "../components/filter-tabs"; import { IntegrationFavicon, integrationInferredUrl, @@ -33,6 +36,10 @@ import { } from "../components/sheet"; import { Skeleton } from "../components/skeleton"; import { + adminAuditActorLabel, + adminAuditResourceLabel, + adminAuditScopeLabel, + auditActionLabel, adminUserCopyableEmail, adminUserTitle, connectionHealthStatus, @@ -46,6 +53,7 @@ import { splitPage, type AdminCatalogRow, type AdminConnectionRow, + type AdminAuditAction, type AdminUserIdentityRow, } from "../lib/admin-users-display"; import { @@ -55,6 +63,7 @@ import { } from "../lib/health-display"; import { isAsyncResultLoading } from "../lib/async-result"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { formatRelativeTime } from "../lib/relative-time"; // --------------------------------------------------------------------------- // Admin · Users — the tenant-wide operator view. @@ -523,8 +532,139 @@ function UserDetail(props: { // ── Page ──────────────────────────────────────────────────────────────────── +type AdminAuditEventRow = { + readonly id: string; + readonly actorId: string | null; + readonly actorEmail: string | null; + readonly actorDisplayName: string | null; + readonly action: AdminAuditAction; + readonly resourceType: "connection" | "integration" | "oauth_client" | "tool_policy"; + readonly resourceOwner: "org" | "user" | null; + readonly resourceParent: string | null; + readonly resourceId: string; + readonly createdAt: number; +}; + +function AuditActivity() { + const [offset, setOffset] = useState(0); + const page = { limit: ADMIN_AUDIT_EVENTS_PAGE_SIZE, offset }; + const result = useAtomValue(adminAuditEventsAtom(page)); + const refresh = useAtomRefresh(adminAuditEventsAtom(page)); + const loading = ( +
+ {[0, 1, 2, 3].map((row) => ( + + ))} +
+ ); + + if (isAsyncResultLoading(result)) return loading; + return AsyncResult.match(result, { + onInitial: () => loading, + onFailure: (failure) => + isAccessDenied(failure.cause) ? ( + + ) : ( + + ), + onSuccess: ({ value }) => { + const { rows, hasNext } = splitPage(value.events, ADMIN_AUDIT_EVENTS_PAGE_SIZE); + if (rows.length === 0) { + return ( +
+

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

+

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

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

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

-

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

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

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

+

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

+ {offset > 0 && ( + + )} +
+ ); + } + + return ( + <> +
+
+ User + Created + Last seen + Connections +
+ {rows.map((user: AdminUserRow) => ( + // oxlint-disable-next-line react/forbid-elements + + ))} +
+ + {(hasNext || offset > 0) && ( +
+ + Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)} + +
- )} -
- ); - } - - return ( - <> -
-
- User - Created - Last seen - Connections -
- {rows.map((user: AdminUserRow) => ( - // oxlint-disable-next-line react/forbid-elements - - ))} -
- - {(hasNext || offset > 0) && ( -
- - Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)} - -
- - -
+ Next +
- )} - - ); - }, - })} +
+ )} + + ); + }, + }) + )} !open && setSelected(null)}> {/* A read-only detail panel: clicking away closes it. */}