diff --git a/.env.example b/.env.example index 41786a8..5c68761 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ CRANK_CACHE_BACKEND=memory CRANK_CACHE_URL= CRANK_OUTBOUND_ALLOWED_HOSTS= CRANK_OUTBOUND_DENIED_HOSTS= +CRANK_OUTBOUND_MAX_REQUEST_BYTES=4194304 CRANK_OUTBOUND_MAX_RESPONSE_BYTES=4194304 CRANK_ENVIRONMENT=development CRANK_LOG_LEVEL= @@ -50,7 +51,7 @@ CRANK_INVOCATION_LOG_RETENTION_DAYS=30 CRANK_SESSION_SECRET= CRANK_PASSWORD_PEPPER= CRANK_SESSION_TTL_HOURS=24 -CRANK_TRUST_FORWARDED_HEADERS=false +CRANK_TRUSTED_PROXY_IPS= CRANK_BOOTSTRAP_ADMIN_EMAIL= CRANK_BOOTSTRAP_ADMIN_PASSWORD= CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner diff --git a/.gitignore b/.gitignore index 6f9a797..812e74f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ TASKS.md # BMAD workspace data and generated artifacts **/_bmad*/ +.bmad-loop/runs/ +.bmad-loop/cache/ +.bmad-loop/policy.toml +_bmad/render/ diff --git a/.sqlx/query-09a122a463d94b08719b16c221d266b9b2061da3eb19f0b16e1069ca30c4e60b.json b/.sqlx/query-09a122a463d94b08719b16c221d266b9b2061da3eb19f0b16e1069ca30c4e60b.json new file mode 100644 index 0000000..b1b90bf --- /dev/null +++ b/.sqlx/query-09a122a463d94b08719b16c221d266b9b2061da3eb19f0b16e1069ca30c4e60b.json @@ -0,0 +1,328 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n where po.operation_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "workspace_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "display_name" + } + } + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "category" + } + } + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "protocol" + } + } + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "security_level" + } + } + }, + { + "ordinal": 7, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 8, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 9, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "published_at" + } + } + }, + { + "ordinal": 10, + "name": "version", + "type_info": "Int4", + "origin": { + "Table": { + "table": "operation_versions", + "name": "version" + } + } + }, + { + "ordinal": 11, + "name": "status", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "status" + } + } + }, + { + "ordinal": 12, + "name": "target_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "target_json" + } + } + }, + { + "ordinal": 13, + "name": "input_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_schema_json" + } + } + }, + { + "ordinal": 14, + "name": "output_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_schema_json" + } + } + }, + { + "ordinal": 15, + "name": "input_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_mapping_json" + } + } + }, + { + "ordinal": 16, + "name": "output_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_mapping_json" + } + } + }, + { + "ordinal": 17, + "name": "execution_config_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "execution_config_json" + } + } + }, + { + "ordinal": 18, + "name": "tool_description_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "tool_description_json" + } + } + }, + { + "ordinal": 19, + "name": "samples_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "samples_json" + } + } + }, + { + "ordinal": 20, + "name": "generated_draft_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "generated_draft_json" + } + } + }, + { + "ordinal": 21, + "name": "config_export_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "config_export_json" + } + } + }, + { + "ordinal": 22, + "name": "wizard_state_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "wizard_state_json" + } + } + }, + { + "ordinal": 23, + "name": "change_note", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "change_note" + } + } + }, + { + "ordinal": 24, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 25, + "name": "created_by", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_by" + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "09a122a463d94b08719b16c221d266b9b2061da3eb19f0b16e1069ca30c4e60b" +} diff --git a/.sqlx/query-0b8e9bed137684683d8b6a9ed52c94b6fb3bbcb19c7a621c78c5a6d3698bdf4d.json b/.sqlx/query-0b8e9bed137684683d8b6a9ed52c94b6fb3bbcb19c7a621c78c5a6d3698bdf4d.json index 2199915..6f36b7a 100644 --- a/.sqlx/query-0b8e9bed137684683d8b6a9ed52c94b6fb3bbcb19c7a621c78c5a6d3698bdf4d.json +++ b/.sqlx/query-0b8e9bed137684683d8b6a9ed52c94b6fb3bbcb19c7a621c78c5a6d3698bdf4d.json @@ -6,72 +6,156 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } }, { "ordinal": 1, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "workspace_id" + } + } }, { "ordinal": 2, "name": "name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "name" + } + } }, { "ordinal": 3, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "display_name" + } + } }, { "ordinal": 4, "name": "category", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "category" + } + } }, { "ordinal": 5, "name": "protocol", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "protocol" + } + } }, { "ordinal": 6, "name": "security_level", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "security_level" + } + } }, { "ordinal": 7, "name": "target_json", - "type_info": "Jsonb" + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "target_json" + } + } }, { "ordinal": 8, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "status" + } + } }, { "ordinal": 9, "name": "current_draft_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "operations", + "name": "current_draft_version" + } + } }, { "ordinal": 10, "name": "latest_published_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "operations", + "name": "latest_published_version" + } + } }, { "ordinal": 11, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operations", + "name": "created_at" + } + } }, { "ordinal": 12, "name": "updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operations", + "name": "updated_at" + } + } }, { "ordinal": 13, "name": "published_at: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operations", + "name": "published_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-0c0a4e5d7b11cd7df7aaf34fd3b7c294da65804186907e015da9420d594fefda.json b/.sqlx/query-0c0a4e5d7b11cd7df7aaf34fd3b7c294da65804186907e015da9420d594fefda.json new file mode 100644 index 0000000..ba18477 --- /dev/null +++ b/.sqlx/query-0c0a4e5d7b11cd7df7aaf34fd3b7c294da65804186907e015da9420d594fefda.json @@ -0,0 +1,330 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "workspace_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "display_name" + } + } + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "category" + } + } + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "protocol" + } + } + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "security_level" + } + } + }, + { + "ordinal": 7, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 8, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 9, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "published_at" + } + } + }, + { + "ordinal": 10, + "name": "version", + "type_info": "Int4", + "origin": { + "Table": { + "table": "operation_versions", + "name": "version" + } + } + }, + { + "ordinal": 11, + "name": "status", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "status" + } + } + }, + { + "ordinal": 12, + "name": "target_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "target_json" + } + } + }, + { + "ordinal": 13, + "name": "input_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_schema_json" + } + } + }, + { + "ordinal": 14, + "name": "output_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_schema_json" + } + } + }, + { + "ordinal": 15, + "name": "input_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_mapping_json" + } + } + }, + { + "ordinal": 16, + "name": "output_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_mapping_json" + } + } + }, + { + "ordinal": 17, + "name": "execution_config_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "execution_config_json" + } + } + }, + { + "ordinal": 18, + "name": "tool_description_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "tool_description_json" + } + } + }, + { + "ordinal": 19, + "name": "samples_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "samples_json" + } + } + }, + { + "ordinal": 20, + "name": "generated_draft_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "generated_draft_json" + } + } + }, + { + "ordinal": 21, + "name": "config_export_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "config_export_json" + } + } + }, + { + "ordinal": 22, + "name": "wizard_state_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "wizard_state_json" + } + } + }, + { + "ordinal": 23, + "name": "change_note", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "change_note" + } + } + }, + { + "ordinal": 24, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 25, + "name": "created_by", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_by" + } + } + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "0c0a4e5d7b11cd7df7aaf34fd3b7c294da65804186907e015da9420d594fefda" +} diff --git a/.sqlx/query-0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb.json b/.sqlx/query-0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb.json deleted file mode 100644 index 38c9190..0000000 --- a/.sqlx/query-0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb.json +++ /dev/null @@ -1,173 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2\n order by ov.version asc", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "workspace_id", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "category", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "protocol", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "security_level", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "operation_created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "operation_updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "operation_published_at: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "version", - "type_info": "Int4" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 12, - "name": "target_json", - "type_info": "Jsonb" - }, - { - "ordinal": 13, - "name": "input_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 14, - "name": "output_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 15, - "name": "input_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 16, - "name": "output_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 17, - "name": "execution_config_json", - "type_info": "Jsonb" - }, - { - "ordinal": 18, - "name": "tool_description_json", - "type_info": "Jsonb" - }, - { - "ordinal": 19, - "name": "samples_json", - "type_info": "Jsonb" - }, - { - "ordinal": 20, - "name": "generated_draft_json", - "type_info": "Jsonb" - }, - { - "ordinal": 21, - "name": "config_export_json", - "type_info": "Jsonb" - }, - { - "ordinal": 22, - "name": "wizard_state_json", - "type_info": "Jsonb" - }, - { - "ordinal": 23, - "name": "change_note", - "type_info": "Text" - }, - { - "ordinal": 24, - "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 25, - "name": "created_by", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - true, - true, - true, - false, - true - ] - }, - "hash": "0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb" -} diff --git a/.sqlx/query-1912ca7a4b00fcc5c1a7fe5434b811977fbdf181436573fa96415ccfcb5fecb2.json b/.sqlx/query-1912ca7a4b00fcc5c1a7fe5434b811977fbdf181436573fa96415ccfcb5fecb2.json index 83da6db..873c727 100644 --- a/.sqlx/query-1912ca7a4b00fcc5c1a7fe5434b811977fbdf181436573fa96415ccfcb5fecb2.json +++ b/.sqlx/query-1912ca7a4b00fcc5c1a7fe5434b811977fbdf181436573fa96415ccfcb5fecb2.json @@ -6,32 +6,68 @@ { "ordinal": 0, "name": "agent_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agent_versions", + "name": "agent_id" + } + } }, { "ordinal": 1, "name": "version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "agent_versions", + "name": "version" + } + } }, { "ordinal": 2, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agent_versions", + "name": "status" + } + } }, { "ordinal": 3, "name": "instructions_json", - "type_info": "Jsonb" + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "agent_versions", + "name": "instructions_json" + } + } }, { "ordinal": 4, "name": "tool_selection_policy_json", - "type_info": "Jsonb" + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "agent_versions", + "name": "tool_selection_policy_json" + } + } }, { "ordinal": 5, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "agent_versions", + "name": "created_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-1b8c77657275ab0ea28e68ae5147dbdad5d055887ef5fabd82552d4fe704f891.json b/.sqlx/query-1b8c77657275ab0ea28e68ae5147dbdad5d055887ef5fabd82552d4fe704f891.json index 06b97ab..f1f35ba 100644 --- a/.sqlx/query-1b8c77657275ab0ea28e68ae5147dbdad5d055887ef5fabd82552d4fe704f891.json +++ b/.sqlx/query-1b8c77657275ab0ea28e68ae5147dbdad5d055887ef5fabd82552d4fe704f891.json @@ -6,42 +6,90 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_samples", + "name": "id" + } + } }, { "ordinal": 1, "name": "operation_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_samples", + "name": "operation_id" + } + } }, { "ordinal": 2, "name": "version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "operation_samples", + "name": "version" + } + } }, { "ordinal": 3, "name": "sample_kind", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_samples", + "name": "sample_kind" + } + } }, { "ordinal": 4, "name": "storage_ref", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_samples", + "name": "storage_ref" + } + } }, { "ordinal": 5, "name": "content_type", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_samples", + "name": "content_type" + } + } }, { "ordinal": 6, "name": "file_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_samples", + "name": "file_name" + } + } }, { "ordinal": 7, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_samples", + "name": "created_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-20a3404581cb52fa7087ba4649fde4910e8d7c2b1b17055fdd1c54206e55a874.json b/.sqlx/query-20a3404581cb52fa7087ba4649fde4910e8d7c2b1b17055fdd1c54206e55a874.json index 6c35de9..e6ed1c9 100644 --- a/.sqlx/query-20a3404581cb52fa7087ba4649fde4910e8d7c2b1b17055fdd1c54206e55a874.json +++ b/.sqlx/query-20a3404581cb52fa7087ba4649fde4910e8d7c2b1b17055fdd1c54206e55a874.json @@ -6,57 +6,123 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "id" + } + } }, { "ordinal": 1, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "workspace_id" + } + } }, { "ordinal": 2, "name": "slug", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "slug" + } + } }, { "ordinal": 3, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "display_name" + } + } }, { "ordinal": 4, "name": "description", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "description" + } + } }, { "ordinal": 5, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "status" + } + } }, { "ordinal": 6, "name": "current_draft_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "agents", + "name": "current_draft_version" + } + } }, { "ordinal": 7, "name": "latest_published_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "agents", + "name": "latest_published_version" + } + } }, { "ordinal": 8, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "agents", + "name": "created_at" + } + } }, { "ordinal": 9, "name": "updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "agents", + "name": "updated_at" + } + } }, { "ordinal": 10, "name": "published_at: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "agents", + "name": "published_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b.json b/.sqlx/query-21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b.json deleted file mode 100644 index b636ccd..0000000 --- a/.sqlx/query-21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b.json +++ /dev/null @@ -1,174 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "workspace_id", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "category", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "protocol", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "security_level", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "operation_created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "operation_updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "operation_published_at: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "version", - "type_info": "Int4" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 12, - "name": "target_json", - "type_info": "Jsonb" - }, - { - "ordinal": 13, - "name": "input_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 14, - "name": "output_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 15, - "name": "input_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 16, - "name": "output_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 17, - "name": "execution_config_json", - "type_info": "Jsonb" - }, - { - "ordinal": 18, - "name": "tool_description_json", - "type_info": "Jsonb" - }, - { - "ordinal": 19, - "name": "samples_json", - "type_info": "Jsonb" - }, - { - "ordinal": 20, - "name": "generated_draft_json", - "type_info": "Jsonb" - }, - { - "ordinal": 21, - "name": "config_export_json", - "type_info": "Jsonb" - }, - { - "ordinal": 22, - "name": "wizard_state_json", - "type_info": "Jsonb" - }, - { - "ordinal": 23, - "name": "change_note", - "type_info": "Text" - }, - { - "ordinal": 24, - "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 25, - "name": "created_by", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Int4" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - true, - true, - true, - false, - true - ] - }, - "hash": "21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b" -} diff --git a/.sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json b/.sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json index e929b28..1afbdcb 100644 --- a/.sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json +++ b/.sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json @@ -6,27 +6,57 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "id" + } + } }, { "ordinal": 1, "name": "email", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "email" + } + } }, { "ordinal": 2, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "display_name" + } + } }, { "ordinal": 3, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "status" + } + } }, { "ordinal": 4, "name": "created_at!: OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "users", + "name": "created_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-2b819bad2a2712dab1a182b89ec52d762d66cffdc449ee6f73c74c1d23a17314.json b/.sqlx/query-2b819bad2a2712dab1a182b89ec52d762d66cffdc449ee6f73c74c1d23a17314.json index 11227cc..2ddbcb1 100644 --- a/.sqlx/query-2b819bad2a2712dab1a182b89ec52d762d66cffdc449ee6f73c74c1d23a17314.json +++ b/.sqlx/query-2b819bad2a2712dab1a182b89ec52d762d66cffdc449ee6f73c74c1d23a17314.json @@ -6,7 +6,8 @@ { "ordinal": 0, "name": "allowed!", - "type_info": "Bool" + "type_info": "Bool", + "origin": "Expression" } ], "parameters": { diff --git a/.sqlx/query-3422060fba1de6d85f89c46b41cc1cf462f49f0e2fe93e9230d15f97710b74ee.json b/.sqlx/query-3422060fba1de6d85f89c46b41cc1cf462f49f0e2fe93e9230d15f97710b74ee.json index e9828b6..a469b50 100644 --- a/.sqlx/query-3422060fba1de6d85f89c46b41cc1cf462f49f0e2fe93e9230d15f97710b74ee.json +++ b/.sqlx/query-3422060fba1de6d85f89c46b41cc1cf462f49f0e2fe93e9230d15f97710b74ee.json @@ -6,7 +6,8 @@ { "ordinal": 0, "name": "present!", - "type_info": "Int4" + "type_info": "Int4", + "origin": "Expression" } ], "parameters": { diff --git a/.sqlx/query-38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25.json b/.sqlx/query-38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25.json deleted file mode 100644 index f459be6..0000000 --- a/.sqlx/query-38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where email = $1\n limit 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "email", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "password_hash!", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "created_at!: OffsetDateTime", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - true, - false, - false - ] - }, - "hash": "38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25" -} diff --git a/.sqlx/query-3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06.json b/.sqlx/query-3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06.json deleted file mode 100644 index f5faed0..0000000 --- a/.sqlx/query-3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where id = $1\n limit 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "email", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "password_hash!", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "created_at!: OffsetDateTime", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - true, - false, - false - ] - }, - "hash": "3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06" -} diff --git a/.sqlx/query-3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08.json b/.sqlx/query-3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08.json deleted file mode 100644 index 83e24cc..0000000 --- a/.sqlx/query-3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "insert into users (\n id,\n email,\n display_name,\n password_hash,\n status,\n created_at\n ) values (\n $1, $2, $3, $4, 'active', now()\n )\n on conflict (email) do update\n set display_name = excluded.display_name,\n password_hash = excluded.password_hash,\n status = 'active'\n returning id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08" -} diff --git a/.sqlx/query-3e015fdc582e6cbbcc0e8d8863873f4e3113fd89f806fb4e0592c0200283f599.json b/.sqlx/query-3e015fdc582e6cbbcc0e8d8863873f4e3113fd89f806fb4e0592c0200283f599.json new file mode 100644 index 0000000..cd8bb40 --- /dev/null +++ b/.sqlx/query-3e015fdc582e6cbbcc0e8d8863873f4e3113fd89f806fb4e0592c0200283f599.json @@ -0,0 +1,329 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2\n order by ov.version asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "workspace_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "display_name" + } + } + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "category" + } + } + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "protocol" + } + } + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "security_level" + } + } + }, + { + "ordinal": 7, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 8, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 9, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "published_at" + } + } + }, + { + "ordinal": 10, + "name": "version", + "type_info": "Int4", + "origin": { + "Table": { + "table": "operation_versions", + "name": "version" + } + } + }, + { + "ordinal": 11, + "name": "status", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "status" + } + } + }, + { + "ordinal": 12, + "name": "target_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "target_json" + } + } + }, + { + "ordinal": 13, + "name": "input_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_schema_json" + } + } + }, + { + "ordinal": 14, + "name": "output_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_schema_json" + } + } + }, + { + "ordinal": 15, + "name": "input_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_mapping_json" + } + } + }, + { + "ordinal": 16, + "name": "output_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_mapping_json" + } + } + }, + { + "ordinal": 17, + "name": "execution_config_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "execution_config_json" + } + } + }, + { + "ordinal": 18, + "name": "tool_description_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "tool_description_json" + } + } + }, + { + "ordinal": 19, + "name": "samples_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "samples_json" + } + } + }, + { + "ordinal": 20, + "name": "generated_draft_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "generated_draft_json" + } + } + }, + { + "ordinal": 21, + "name": "config_export_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "config_export_json" + } + } + }, + { + "ordinal": 22, + "name": "wizard_state_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "wizard_state_json" + } + } + }, + { + "ordinal": 23, + "name": "change_note", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "change_note" + } + } + }, + { + "ordinal": 24, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 25, + "name": "created_by", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_by" + } + } + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "3e015fdc582e6cbbcc0e8d8863873f4e3113fd89f806fb4e0592c0200283f599" +} diff --git a/.sqlx/query-4699ba47e6489b840ada38a7491f071f605dabd061992cd0a567241a8de1fa0c.json b/.sqlx/query-4699ba47e6489b840ada38a7491f071f605dabd061992cd0a567241a8de1fa0c.json new file mode 100644 index 0000000..7a5a6e8 --- /dev/null +++ b/.sqlx/query-4699ba47e6489b840ada38a7491f071f605dabd061992cd0a567241a8de1fa0c.json @@ -0,0 +1,396 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n w.id as workspace_id,\n w.slug as workspace_slug,\n a.id as agent_id,\n a.slug as agent_slug,\n b.tool_name,\n b.tool_title,\n coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",\n o.id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version\n join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version\n join operations o on o.id = ov.operation_id and o.workspace_id = w.id\n where w.slug = $1 and a.slug = $2 and b.enabled = true\n order by b.tool_name asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "workspace_slug", + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "slug" + } + } + }, + { + "ordinal": 2, + "name": "agent_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "id" + } + } + }, + { + "ordinal": 3, + "name": "agent_slug", + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "slug" + } + } + }, + { + "ordinal": 4, + "name": "tool_name", + "type_info": "Text", + "origin": { + "Table": { + "table": "agent_operation_bindings", + "name": "tool_name" + } + } + }, + { + "ordinal": 5, + "name": "tool_title", + "type_info": "Text", + "origin": { + "Table": { + "table": "agent_operation_bindings", + "name": "tool_title" + } + } + }, + { + "ordinal": 6, + "name": "tool_description!", + "type_info": "Text", + "origin": "Expression" + }, + { + "ordinal": 7, + "name": "id", + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } + }, + { + "ordinal": 8, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "name" + } + } + }, + { + "ordinal": 9, + "name": "display_name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "display_name" + } + } + }, + { + "ordinal": 10, + "name": "category", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "category" + } + } + }, + { + "ordinal": 11, + "name": "protocol", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "protocol" + } + } + }, + { + "ordinal": 12, + "name": "security_level", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "security_level" + } + } + }, + { + "ordinal": 13, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 14, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 15, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "published_at" + } + } + }, + { + "ordinal": 16, + "name": "version", + "type_info": "Int4", + "origin": { + "Table": { + "table": "operation_versions", + "name": "version" + } + } + }, + { + "ordinal": 17, + "name": "status", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "status" + } + } + }, + { + "ordinal": 18, + "name": "target_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "target_json" + } + } + }, + { + "ordinal": 19, + "name": "input_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_schema_json" + } + } + }, + { + "ordinal": 20, + "name": "output_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_schema_json" + } + } + }, + { + "ordinal": 21, + "name": "input_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_mapping_json" + } + } + }, + { + "ordinal": 22, + "name": "output_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_mapping_json" + } + } + }, + { + "ordinal": 23, + "name": "execution_config_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "execution_config_json" + } + } + }, + { + "ordinal": 24, + "name": "tool_description_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "tool_description_json" + } + } + }, + { + "ordinal": 25, + "name": "samples_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "samples_json" + } + } + }, + { + "ordinal": 26, + "name": "generated_draft_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "generated_draft_json" + } + } + }, + { + "ordinal": 27, + "name": "config_export_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "config_export_json" + } + } + }, + { + "ordinal": 28, + "name": "wizard_state_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "wizard_state_json" + } + } + }, + { + "ordinal": 29, + "name": "change_note", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "change_note" + } + } + }, + { + "ordinal": 30, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 31, + "name": "created_by", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_by" + } + } + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + null, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "4699ba47e6489b840ada38a7491f071f605dabd061992cd0a567241a8de1fa0c" +} diff --git a/.sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json b/.sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json index 7317c3c..fe88683 100644 --- a/.sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json +++ b/.sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json @@ -6,37 +6,79 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "id" + } + } }, { "ordinal": 1, "name": "slug", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "slug" + } + } }, { "ordinal": 2, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "display_name" + } + } }, { "ordinal": 3, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "status" + } + } }, { "ordinal": 4, "name": "settings_json", - "type_info": "Jsonb" + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "workspaces", + "name": "settings_json" + } + } }, { "ordinal": 5, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "workspaces", + "name": "created_at" + } + } }, { "ordinal": 6, "name": "updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "workspaces", + "name": "updated_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-6ae136d8196f7b259b7e5d054ecbd063dc5746ef669f21b0db1b5843e9db22d0.json b/.sqlx/query-6ae136d8196f7b259b7e5d054ecbd063dc5746ef669f21b0db1b5843e9db22d0.json index ff6c9a4..fff64c5 100644 --- a/.sqlx/query-6ae136d8196f7b259b7e5d054ecbd063dc5746ef669f21b0db1b5843e9db22d0.json +++ b/.sqlx/query-6ae136d8196f7b259b7e5d054ecbd063dc5746ef669f21b0db1b5843e9db22d0.json @@ -6,42 +6,90 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "descriptors", + "name": "id" + } + } }, { "ordinal": 1, "name": "operation_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "descriptors", + "name": "operation_id" + } + } }, { "ordinal": 2, "name": "version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "descriptors", + "name": "version" + } + } }, { "ordinal": 3, "name": "descriptor_kind", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "descriptors", + "name": "descriptor_kind" + } + } }, { "ordinal": 4, "name": "storage_ref", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "descriptors", + "name": "storage_ref" + } + } }, { "ordinal": 5, "name": "source_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "descriptors", + "name": "source_name" + } + } }, { "ordinal": 6, "name": "package_index_json", - "type_info": "Jsonb" + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "descriptors", + "name": "package_index_json" + } + } }, { "ordinal": 7, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "descriptors", + "name": "created_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-6eed48468f97843bbcd6a2f90448b68c15efa72029740e28bb881f54f73a6b9b.json b/.sqlx/query-6eed48468f97843bbcd6a2f90448b68c15efa72029740e28bb881f54f73a6b9b.json index c66b1b0..dc7c224 100644 --- a/.sqlx/query-6eed48468f97843bbcd6a2f90448b68c15efa72029740e28bb881f54f73a6b9b.json +++ b/.sqlx/query-6eed48468f97843bbcd6a2f90448b68c15efa72029740e28bb881f54f73a6b9b.json @@ -6,7 +6,8 @@ { "ordinal": 0, "name": "present!", - "type_info": "Int4" + "type_info": "Int4", + "origin": "Expression" } ], "parameters": { diff --git a/.sqlx/query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json b/.sqlx/query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json deleted file mode 100644 index 415db6d..0000000 --- a/.sqlx/query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select\n w.id,\n w.slug,\n w.display_name,\n w.status,\n w.settings_json,\n w.created_at as \"created_at!: time::OffsetDateTime\",\n w.updated_at as \"updated_at!: time::OffsetDateTime\",\n m.role\n from memberships m\n join workspaces w on w.id = m.workspace_id\n where m.user_id = $1\n order by w.slug asc", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "slug", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "settings_json", - "type_info": "Jsonb" - }, - { - "ordinal": 5, - "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "role", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb" -} diff --git a/.sqlx/query-94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce.json b/.sqlx/query-94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce.json deleted file mode 100644 index 8731e92..0000000 --- a/.sqlx/query-94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n where po.operation_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "workspace_id", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "category", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "protocol", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "security_level", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "operation_created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "operation_updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "operation_published_at: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "version", - "type_info": "Int4" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 12, - "name": "target_json", - "type_info": "Jsonb" - }, - { - "ordinal": 13, - "name": "input_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 14, - "name": "output_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 15, - "name": "input_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 16, - "name": "output_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 17, - "name": "execution_config_json", - "type_info": "Jsonb" - }, - { - "ordinal": 18, - "name": "tool_description_json", - "type_info": "Jsonb" - }, - { - "ordinal": 19, - "name": "samples_json", - "type_info": "Jsonb" - }, - { - "ordinal": 20, - "name": "generated_draft_json", - "type_info": "Jsonb" - }, - { - "ordinal": 21, - "name": "config_export_json", - "type_info": "Jsonb" - }, - { - "ordinal": 22, - "name": "wizard_state_json", - "type_info": "Jsonb" - }, - { - "ordinal": 23, - "name": "change_note", - "type_info": "Text" - }, - { - "ordinal": 24, - "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 25, - "name": "created_by", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - true, - true, - true, - false, - true - ] - }, - "hash": "94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce" -} diff --git a/.sqlx/query-9625d5160a075b55b5bb980ea8e82e9da706a34d17adf0bc94ddb4e7a3818c18.json b/.sqlx/query-9625d5160a075b55b5bb980ea8e82e9da706a34d17adf0bc94ddb4e7a3818c18.json index f09acbf..81cd9aa 100644 --- a/.sqlx/query-9625d5160a075b55b5bb980ea8e82e9da706a34d17adf0bc94ddb4e7a3818c18.json +++ b/.sqlx/query-9625d5160a075b55b5bb980ea8e82e9da706a34d17adf0bc94ddb4e7a3818c18.json @@ -6,57 +6,123 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "id" + } + } }, { "ordinal": 1, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "workspace_id" + } + } }, { "ordinal": 2, "name": "slug", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "slug" + } + } }, { "ordinal": 3, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "display_name" + } + } }, { "ordinal": 4, "name": "description", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "description" + } + } }, { "ordinal": 5, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "status" + } + } }, { "ordinal": 6, "name": "current_draft_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "agents", + "name": "current_draft_version" + } + } }, { "ordinal": 7, "name": "latest_published_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "agents", + "name": "latest_published_version" + } + } }, { "ordinal": 8, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "agents", + "name": "created_at" + } + } }, { "ordinal": 9, "name": "updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "agents", + "name": "updated_at" + } + } }, { "ordinal": 10, "name": "published_at: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "agents", + "name": "published_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-b531368c52ef70664dfe75fe801e52e70fc74af8e782989cf4aea3f1170d33e5.json b/.sqlx/query-b531368c52ef70664dfe75fe801e52e70fc74af8e782989cf4aea3f1170d33e5.json new file mode 100644 index 0000000..cf1e38c --- /dev/null +++ b/.sqlx/query-b531368c52ef70664dfe75fe801e52e70fc74af8e782989cf4aea3f1170d33e5.json @@ -0,0 +1,326 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n order by o.name asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "workspace_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "display_name" + } + } + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "category" + } + } + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "protocol" + } + } + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "security_level" + } + } + }, + { + "ordinal": 7, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 8, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 9, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "published_at" + } + } + }, + { + "ordinal": 10, + "name": "version", + "type_info": "Int4", + "origin": { + "Table": { + "table": "operation_versions", + "name": "version" + } + } + }, + { + "ordinal": 11, + "name": "status", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "status" + } + } + }, + { + "ordinal": 12, + "name": "target_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "target_json" + } + } + }, + { + "ordinal": 13, + "name": "input_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_schema_json" + } + } + }, + { + "ordinal": 14, + "name": "output_schema_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_schema_json" + } + } + }, + { + "ordinal": 15, + "name": "input_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "input_mapping_json" + } + } + }, + { + "ordinal": 16, + "name": "output_mapping_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "output_mapping_json" + } + } + }, + { + "ordinal": 17, + "name": "execution_config_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "execution_config_json" + } + } + }, + { + "ordinal": 18, + "name": "tool_description_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "tool_description_json" + } + } + }, + { + "ordinal": 19, + "name": "samples_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "samples_json" + } + } + }, + { + "ordinal": 20, + "name": "generated_draft_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "generated_draft_json" + } + } + }, + { + "ordinal": 21, + "name": "config_export_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "config_export_json" + } + } + }, + { + "ordinal": 22, + "name": "wizard_state_json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "wizard_state_json" + } + } + }, + { + "ordinal": 23, + "name": "change_note", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "change_note" + } + } + }, + { + "ordinal": 24, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_at" + } + } + }, + { + "ordinal": 25, + "name": "created_by", + "type_info": "Text", + "origin": { + "Table": { + "table": "operation_versions", + "name": "created_by" + } + } + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "b531368c52ef70664dfe75fe801e52e70fc74af8e782989cf4aea3f1170d33e5" +} diff --git a/.sqlx/query-b86a7406eeec83390458e35fb8611e19377582ec0ca245a162c2d2236077bb9b.json b/.sqlx/query-b86a7406eeec83390458e35fb8611e19377582ec0ca245a162c2d2236077bb9b.json index e74aa00..273daa1 100644 --- a/.sqlx/query-b86a7406eeec83390458e35fb8611e19377582ec0ca245a162c2d2236077bb9b.json +++ b/.sqlx/query-b86a7406eeec83390458e35fb8611e19377582ec0ca245a162c2d2236077bb9b.json @@ -6,37 +6,79 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "id" + } + } }, { "ordinal": 1, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "workspace_id" + } + } }, { "ordinal": 2, "name": "name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "name" + } + } }, { "ordinal": 3, "name": "kind", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "kind" + } + } }, { "ordinal": 4, "name": "config_json", - "type_info": "Jsonb" + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "config_json" + } + } }, { "ordinal": 5, "name": "created_at!: OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "created_at" + } + } }, { "ordinal": 6, "name": "updated_at!: OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "updated_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c.json b/.sqlx/query-bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c.json deleted file mode 100644 index 8762d72..0000000 --- a/.sqlx/query-bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select\n id,\n protocol_version,\n initialized,\n workspace_slug,\n agent_slug,\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n expires_at as \"expires_at: OffsetDateTime\"\n from mcp_transport_sessions\n where id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "protocol_version", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "initialized", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "workspace_slug", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "agent_slug", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "created_at!: OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "updated_at!: OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "expires_at: OffsetDateTime", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - true - ] - }, - "hash": "bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c" -} diff --git a/.sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json b/.sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json index f88bd2c..4306c84 100644 --- a/.sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json +++ b/.sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json @@ -6,42 +6,90 @@ { "ordinal": 0, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "memberships", + "name": "workspace_id" + } + } }, { "ordinal": 1, "name": "user_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "memberships", + "name": "user_id" + } + } }, { "ordinal": 2, "name": "role", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "memberships", + "name": "role" + } + } }, { "ordinal": 3, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "memberships", + "name": "created_at" + } + } }, { "ordinal": 4, "name": "email", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "email" + } + } }, { "ordinal": 5, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "display_name" + } + } }, { "ordinal": 6, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "status" + } + } }, { "ordinal": 7, "name": "user_created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "users", + "name": "created_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec.json b/.sqlx/query-c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec.json deleted file mode 100644 index 362db33..0000000 --- a/.sqlx/query-c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec.json +++ /dev/null @@ -1,209 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select\n w.id as workspace_id,\n w.slug as workspace_slug,\n a.id as agent_id,\n a.slug as agent_slug,\n b.tool_name,\n b.tool_title,\n coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",\n o.id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version\n join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version\n join operations o on o.id = ov.operation_id and o.workspace_id = w.id\n where w.slug = $1 and a.slug = $2 and b.enabled = true\n order by b.tool_name asc", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "workspace_id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "workspace_slug", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "agent_id", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "agent_slug", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "tool_name", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "tool_title", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "tool_description!", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 9, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 10, - "name": "category", - "type_info": "Text" - }, - { - "ordinal": 11, - "name": "protocol", - "type_info": "Text" - }, - { - "ordinal": 12, - "name": "security_level", - "type_info": "Text" - }, - { - "ordinal": 13, - "name": "operation_created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 14, - "name": "operation_updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 15, - "name": "operation_published_at: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 16, - "name": "version", - "type_info": "Int4" - }, - { - "ordinal": 17, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 18, - "name": "target_json", - "type_info": "Jsonb" - }, - { - "ordinal": 19, - "name": "input_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 20, - "name": "output_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 21, - "name": "input_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 22, - "name": "output_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 23, - "name": "execution_config_json", - "type_info": "Jsonb" - }, - { - "ordinal": 24, - "name": "tool_description_json", - "type_info": "Jsonb" - }, - { - "ordinal": 25, - "name": "samples_json", - "type_info": "Jsonb" - }, - { - "ordinal": 26, - "name": "generated_draft_json", - "type_info": "Jsonb" - }, - { - "ordinal": 27, - "name": "config_export_json", - "type_info": "Jsonb" - }, - { - "ordinal": 28, - "name": "wizard_state_json", - "type_info": "Jsonb" - }, - { - "ordinal": 29, - "name": "change_note", - "type_info": "Text" - }, - { - "ordinal": 30, - "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 31, - "name": "created_by", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - null, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - true, - true, - true, - false, - true - ] - }, - "hash": "c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec" -} diff --git a/.sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json b/.sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json index 65e4484..ffa809d 100644 --- a/.sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json +++ b/.sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json @@ -6,47 +6,101 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "id" + } + } }, { "ordinal": 1, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "workspace_id" + } + } }, { "ordinal": 2, "name": "name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "name" + } + } }, { "ordinal": 3, "name": "kind", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "kind" + } + } }, { "ordinal": 4, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "status" + } + } }, { "ordinal": 5, "name": "current_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "secrets", + "name": "current_version" + } + } }, { "ordinal": 6, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "secrets", + "name": "created_at" + } + } }, { "ordinal": 7, "name": "updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "secrets", + "name": "updated_at" + } + } }, { "ordinal": 8, "name": "last_used_at: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "secrets", + "name": "last_used_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json b/.sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json index 3893be5..972a0cd 100644 --- a/.sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json +++ b/.sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json @@ -6,37 +6,79 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "id" + } + } }, { "ordinal": 1, "name": "slug", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "slug" + } + } }, { "ordinal": 2, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "display_name" + } + } }, { "ordinal": 3, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "workspaces", + "name": "status" + } + } }, { "ordinal": 4, "name": "settings_json", - "type_info": "Jsonb" + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "workspaces", + "name": "settings_json" + } + } }, { "ordinal": 5, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "workspaces", + "name": "created_at" + } + } }, { "ordinal": 6, "name": "updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "workspaces", + "name": "updated_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json b/.sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json index e27ad9f..fd414df 100644 --- a/.sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json +++ b/.sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json @@ -6,47 +6,101 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "id" + } + } }, { "ordinal": 1, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "workspace_id" + } + } }, { "ordinal": 2, "name": "name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "name" + } + } }, { "ordinal": 3, "name": "kind", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "kind" + } + } }, { "ordinal": 4, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secrets", + "name": "status" + } + } }, { "ordinal": 5, "name": "current_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "secrets", + "name": "current_version" + } + } }, { "ordinal": 6, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "secrets", + "name": "created_at" + } + } }, { "ordinal": 7, "name": "updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "secrets", + "name": "updated_at" + } + } }, { "ordinal": 8, "name": "last_used_at: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "secrets", + "name": "last_used_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json b/.sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json index 03e3aa1..3655e4f 100644 --- a/.sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json +++ b/.sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json @@ -6,42 +6,90 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "invitation_tokens", + "name": "id" + } + } }, { "ordinal": 1, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "invitation_tokens", + "name": "workspace_id" + } + } }, { "ordinal": 2, "name": "email", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "invitation_tokens", + "name": "email" + } + } }, { "ordinal": 3, "name": "role", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "invitation_tokens", + "name": "role" + } + } }, { "ordinal": 4, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "invitation_tokens", + "name": "status" + } + } }, { "ordinal": 5, "name": "token_hash", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "invitation_tokens", + "name": "token_hash" + } + } }, { "ordinal": 6, "name": "expires_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "invitation_tokens", + "name": "expires_at" + } + } }, { "ordinal": 7, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "invitation_tokens", + "name": "created_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-f195cc4951ef129f4a1c95d6d9e8f932919d2882f3dcb5a7f865fb4448506d05.json b/.sqlx/query-f195cc4951ef129f4a1c95d6d9e8f932919d2882f3dcb5a7f865fb4448506d05.json index 3be6a67..efc5411 100644 --- a/.sqlx/query-f195cc4951ef129f4a1c95d6d9e8f932919d2882f3dcb5a7f865fb4448506d05.json +++ b/.sqlx/query-f195cc4951ef129f4a1c95d6d9e8f932919d2882f3dcb5a7f865fb4448506d05.json @@ -6,72 +6,156 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } }, { "ordinal": 1, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "workspace_id" + } + } }, { "ordinal": 2, "name": "name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "name" + } + } }, { "ordinal": 3, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "display_name" + } + } }, { "ordinal": 4, "name": "category", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "category" + } + } }, { "ordinal": 5, "name": "protocol", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "protocol" + } + } }, { "ordinal": 6, "name": "security_level", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "security_level" + } + } }, { "ordinal": 7, "name": "target_json", - "type_info": "Jsonb" + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operation_versions", + "name": "target_json" + } + } }, { "ordinal": 8, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "operations", + "name": "status" + } + } }, { "ordinal": 9, "name": "current_draft_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "operations", + "name": "current_draft_version" + } + } }, { "ordinal": 10, "name": "latest_published_version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "operations", + "name": "latest_published_version" + } + } }, { "ordinal": 11, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operations", + "name": "created_at" + } + } }, { "ordinal": 12, "name": "updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operations", + "name": "updated_at" + } + } }, { "ordinal": 13, "name": "published_at: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operations", + "name": "published_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json b/.sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json index 2ea224a..22833b6 100644 --- a/.sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json +++ b/.sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json @@ -6,37 +6,79 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "user_sessions", + "name": "id" + } + } }, { "ordinal": 1, "name": "user_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "user_sessions", + "name": "user_id" + } + } }, { "ordinal": 2, "name": "current_workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "user_sessions", + "name": "current_workspace_id" + } + } }, { "ordinal": 3, "name": "email", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "email" + } + } }, { "ordinal": 4, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "display_name" + } + } }, { "ordinal": 5, "name": "status", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "status" + } + } }, { "ordinal": 6, "name": "created_at!: OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "users", + "name": "created_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-fadf243775a355c46ae97d26049793eb1d1ad17505b1e1609a842750e3233ec2.json b/.sqlx/query-fadf243775a355c46ae97d26049793eb1d1ad17505b1e1609a842750e3233ec2.json index 33bcc6e..d921759 100644 --- a/.sqlx/query-fadf243775a355c46ae97d26049793eb1d1ad17505b1e1609a842750e3233ec2.json +++ b/.sqlx/query-fadf243775a355c46ae97d26049793eb1d1ad17505b1e1609a842750e3233ec2.json @@ -6,37 +6,79 @@ { "ordinal": 0, "name": "id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "id" + } + } }, { "ordinal": 1, "name": "workspace_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "workspace_id" + } + } }, { "ordinal": 2, "name": "name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "name" + } + } }, { "ordinal": 3, "name": "kind", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "kind" + } + } }, { "ordinal": 4, "name": "config_json", - "type_info": "Jsonb" + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "config_json" + } + } }, { "ordinal": 5, "name": "created_at!: OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "created_at" + } + } }, { "ordinal": 6, "name": "updated_at!: OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "auth_profiles", + "name": "updated_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json b/.sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json index ed3c067..903c63d 100644 --- a/.sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json +++ b/.sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json @@ -6,32 +6,68 @@ { "ordinal": 0, "name": "secret_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secret_versions", + "name": "secret_id" + } + } }, { "ordinal": 1, "name": "version", - "type_info": "Int4" + "type_info": "Int4", + "origin": { + "Table": { + "table": "secret_versions", + "name": "version" + } + } }, { "ordinal": 2, "name": "ciphertext", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secret_versions", + "name": "ciphertext" + } + } }, { "ordinal": 3, "name": "key_version", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secret_versions", + "name": "key_version" + } + } }, { "ordinal": 4, "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "secret_versions", + "name": "created_at" + } + } }, { "ordinal": 5, "name": "created_by", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "secret_versions", + "name": "created_by" + } + } } ], "parameters": { diff --git a/.sqlx/query-fdd05b145021a19dda9a9f44c31ff21a2f73a4416d0cf20be418bba36def9eb4.json b/.sqlx/query-fdd05b145021a19dda9a9f44c31ff21a2f73a4416d0cf20be418bba36def9eb4.json index 8896b79..47c5483 100644 --- a/.sqlx/query-fdd05b145021a19dda9a9f44c31ff21a2f73a4416d0cf20be418bba36def9eb4.json +++ b/.sqlx/query-fdd05b145021a19dda9a9f44c31ff21a2f73a4416d0cf20be418bba36def9eb4.json @@ -6,22 +6,46 @@ { "ordinal": 0, "name": "operation_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agent_operation_bindings", + "name": "operation_id" + } + } }, { "ordinal": 1, "name": "agent_id", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "id" + } + } }, { "ordinal": 2, "name": "agent_slug", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "slug" + } + } }, { "ordinal": 3, "name": "display_name", - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "agents", + "name": "display_name" + } + } } ], "parameters": { diff --git a/.sqlx/query-ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4.json b/.sqlx/query-ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4.json deleted file mode 100644 index b2d8158..0000000 --- a/.sqlx/query-ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n order by o.name asc", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "workspace_id", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "category", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "protocol", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "security_level", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "operation_created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "operation_updated_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "operation_published_at: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "version", - "type_info": "Int4" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 12, - "name": "target_json", - "type_info": "Jsonb" - }, - { - "ordinal": 13, - "name": "input_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 14, - "name": "output_schema_json", - "type_info": "Jsonb" - }, - { - "ordinal": 15, - "name": "input_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 16, - "name": "output_mapping_json", - "type_info": "Jsonb" - }, - { - "ordinal": 17, - "name": "execution_config_json", - "type_info": "Jsonb" - }, - { - "ordinal": 18, - "name": "tool_description_json", - "type_info": "Jsonb" - }, - { - "ordinal": 19, - "name": "samples_json", - "type_info": "Jsonb" - }, - { - "ordinal": 20, - "name": "generated_draft_json", - "type_info": "Jsonb" - }, - { - "ordinal": 21, - "name": "config_export_json", - "type_info": "Jsonb" - }, - { - "ordinal": 22, - "name": "wizard_state_json", - "type_info": "Jsonb" - }, - { - "ordinal": 23, - "name": "change_note", - "type_info": "Text" - }, - { - "ordinal": 24, - "name": "created_at!: time::OffsetDateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 25, - "name": "created_by", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - true, - true, - true, - false, - true - ] - }, - "hash": "ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4" -} diff --git a/Cargo.lock b/Cargo.lock index 1e63ff1..35c5e9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,7 @@ dependencies = [ "axum-extra", "base64", "crank-community-auth", + "crank-community-mcp", "crank-config", "crank-core", "crank-import", @@ -57,6 +58,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "url", "uuid", ] @@ -1113,7 +1115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1389,9 +1391,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -1638,7 +1640,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -2157,7 +2159,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2574,7 +2576,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -2613,9 +2615,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2953,7 +2955,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3012,7 +3014,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4410,7 +4412,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 3f8117e..13faa4f 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -41,10 +41,12 @@ time.workspace = true tokio = { workspace = true, features = ["fs"] } tracing.workspace = true tracing-subscriber.workspace = true +url.workspace = true uuid.workspace = true [dev-dependencies] async-trait = "0.1" +crank-community-mcp = { path = "../../crates/crank-community-mcp" } crank-test-support = { path = "../../crates/crank-test-support" } metrics.workspace = true metrics-util = "0.20.4" diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 51b2b22..e190032 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -1,10 +1,15 @@ use axum::{ - Router, middleware, + Router, + extract::DefaultBodyLimit, + middleware, routing::{delete, get, post}, }; use crate::{ - auth::{require_session, require_workspace_session}, + auth::{ + enforce_browser_security, require_csrf_for_browser_mutations, require_session, + require_workspace_session, + }, rate_limit::apply_api_rate_limit, request_context::apply_request_context, routes::{ @@ -15,14 +20,18 @@ use crate::{ list_agent_platform_api_keys, list_agents, preview_tool_search, publish_agent, revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent, }, - auth::{change_password, get_profile, get_session, login, logout, update_profile}, + auth::{ + bootstrap_status, change_password, complete_bootstrap, get_profile, get_session, login, + logout, refresh_session_csrf, update_profile, + }, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, capabilities::get_capabilities, imports::{create_openapi_import, preview_openapi_import}, observability::{ - get_agent_usage, get_approval, get_log, get_operation_usage, get_usage, list_approvals, - list_logs, + approve_approval, deny_approval, export_logs_csv, export_usage_csv, get_agent_usage, + get_approval, get_log, get_operation_usage, get_usage, list_approvals, list_logs, }, + onboarding::{get_onboarding, record_onboarding_event, reset_onboarding_selection}, operations::{ analyze_operation_quality, archive_operation, create_operation, create_version, delete_operation, export_operation, generate_draft, get_operation, @@ -48,7 +57,10 @@ pub fn build_app(state: AppState) -> Router { "/operations/analyze-quality", post(analyze_operation_quality), ) - .route("/operations/import", post(import_operation)) + .route( + "/operations/import", + post(import_operation).layer(DefaultBodyLimit::max(256 * 1024)), + ) .route( "/operations/{operation_id}", get(get_operation) @@ -126,12 +138,22 @@ pub fn build_app(state: AppState) -> Router { .route("/secrets/{secret_id}/rotate", post(rotate_secret)) .route("/export", get(export_workspace)) .route("/logs", get(list_logs)) + .route("/logs/export.csv", get(export_logs_csv)) .route("/logs/{log_id}", get(get_log)) .route("/approvals", get(list_approvals)) .route("/approvals/{approval_id}", get(get_approval)) + .route("/approvals/{approval_id}/approve", post(approve_approval)) + .route("/approvals/{approval_id}/deny", post(deny_approval)) + .route("/usage/export.csv", get(export_usage_csv)) .route("/usage", get(get_usage)) .route("/usage/operations/{operation_id}", get(get_operation_usage)) - .route("/usage/agents/{agent_id}", get(get_agent_usage)); + .route("/usage/agents/{agent_id}", get(get_agent_usage)) + .route("/onboarding", get(get_onboarding)) + .route("/onboarding/events", post(record_onboarding_event)) + .route( + "/onboarding/reset-selection", + post(reset_onboarding_selection), + ); let workspace_root_router = Router::new() .route("/capabilities", get(get_capabilities)) @@ -157,6 +179,7 @@ pub fn build_app(state: AppState) -> Router { let protected_auth_router = Router::new() .route("/logout", post(logout)) .route("/session", get(get_session)) + .route("/session/csrf", post(refresh_session_csrf)) .route("/profile", get(get_profile).patch(update_profile)) .route("/password", post(change_password)) .layer(middleware::from_fn_with_state( @@ -170,10 +193,20 @@ pub fn build_app(state: AppState) -> Router { .nest( "/api/auth", Router::new() + .route("/bootstrap/status", get(bootstrap_status)) + .route("/bootstrap/complete", post(complete_bootstrap)) .route("/login", post(login)) .merge(protected_auth_router), ) .nest("/api/admin", admin_router) + .layer(middleware::from_fn_with_state( + state.clone(), + require_csrf_for_browser_mutations, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + enforce_browser_security, + )) .layer(middleware::from_fn_with_state( state.clone(), apply_api_rate_limit, diff --git a/apps/admin-api/src/auth.rs b/apps/admin-api/src/auth.rs index 7548ba1..737f5eb 100644 --- a/apps/admin-api/src/auth.rs +++ b/apps/admin-api/src/auth.rs @@ -1,22 +1,24 @@ use axum::{ extract::{OriginalUri, Request, State}, + http::{HeaderValue, Method, header}, middleware::Next, response::Response, }; use axum_extra::extract::cookie::{Cookie, CookieJar}; use crank_community_auth::{ - cleared_session_cookie as build_cleared_session_cookie, + cleared_session_cookie as build_cleared_session_cookie, create_csrf_token as build_csrf_token, create_session_cookie as build_session_cookie, hash_password as community_hash_password, session_cookie as build_session_cookie_header, }; -use crank_core::{User, UserSessionId, WorkspaceId}; +use crank_core::{MembershipRole, User, UserSessionId, WorkspaceId}; use crank_registry::WorkspaceMembershipRecord; use serde::Serialize; use crate::{error::ApiError, state::AppState}; pub use crank_community_auth::{ - SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password, + SESSION_COOKIE_NAME, SessionCookie, create_csrf_token, extract_session_token, hash_csrf_token, + hash_session_secret, verify_password, }; #[derive(Clone)] @@ -53,6 +55,10 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result String { + build_csrf_token() +} + pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> { build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours) } @@ -90,11 +96,81 @@ pub async fn require_workspace_session( if !has_access { return Err(ApiError::forbidden("workspace access denied")); } + if !matches!( + request.method(), + &axum::http::Method::GET | &axum::http::Method::HEAD + ) && !session.memberships.iter().any(|membership| { + membership.workspace.id == workspace_id && membership.role == MembershipRole::Owner + }) { + return Err(ApiError::forbidden("workspace owner access required")); + } request.extensions_mut().insert(session); Ok(next.run(request).await) } +pub async fn require_csrf_for_browser_mutations( + State(state): State, + jar: CookieJar, + request: Request, + next: Next, +) -> Result { + if !requires_csrf(request.method(), request.uri().path()) { + return Ok(next.run(request).await); + } + let (session_id, _session_value) = extract_session_token(&jar) + .ok_or_else(|| ApiError::unauthorized("authentication required"))?; + let token = request + .headers() + .get("x-csrf-token") + .and_then(|value| value.to_str().ok()) + .filter(|value| valid_csrf_token(value)) + .ok_or_else(|| ApiError::forbidden("csrf validation failed"))?; + let csrf_hash = hash_csrf_token( + &session_id, + token, + &state.service.auth_settings().session_secret, + ); + if !state + .service + .verify_session_csrf(&session_id, &csrf_hash) + .await? + { + return Err(ApiError::forbidden("csrf validation failed")); + } + Ok(next.run(request).await) +} + +pub async fn enforce_browser_security( + State(state): State, + request: Request, + next: Next, +) -> Result { + if let Some(origin) = request.headers().get(header::ORIGIN) + && is_cross_origin( + origin, + request.headers().get(header::HOST), + if state.service.auth_settings().cookie_secure { + "https" + } else { + "http" + }, + ) + && request.uri().path().starts_with("/api/") + { + return Err(ApiError::forbidden("cross-origin admin request denied")); + } + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + headers.insert("x-frame-options", HeaderValue::from_static("DENY")); + headers.insert("referrer-policy", HeaderValue::from_static("no-referrer")); + Ok(response) +} + async fn resolve_authenticated_session( state: AppState, jar: &CookieJar, @@ -122,3 +198,55 @@ fn workspace_id_from_path(path: &str) -> Option { Some(WorkspaceId::new(workspace_id)) } + +fn requires_csrf(method: &Method, path: &str) -> bool { + if matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) { + return false; + } + if matches!( + path, + "/api/auth/login" | "/api/auth/bootstrap/complete" | "/api/auth/session/csrf" + ) { + return false; + } + path.starts_with("/api/auth/") || path.starts_with("/api/admin/") +} + +fn valid_csrf_token(value: &str) -> bool { + (32..=256).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn is_cross_origin( + origin: &HeaderValue, + host: Option<&HeaderValue>, + expected_scheme: &str, +) -> bool { + let Some(host) = host.and_then(|value| value.to_str().ok()) else { + return true; + }; + let Some(origin) = origin.to_str().ok() else { + return true; + }; + let Ok(url) = url::Url::parse(origin) else { + return true; + }; + url.host_str() + .zip(url.port_or_known_default()) + .map(|(origin_host, origin_port)| { + let origin_authority = format!("{}://{origin_host}:{origin_port}", url.scheme()); + let request_authority = if host.contains(':') { + format!("{expected_scheme}://{}", host.to_ascii_lowercase()) + } else { + let default_port = if expected_scheme == "https" { 443 } else { 80 }; + format!( + "{expected_scheme}://{}:{default_port}", + host.to_ascii_lowercase() + ) + }; + origin_authority.to_ascii_lowercase() != request_authority + }) + .unwrap_or(true) +} diff --git a/apps/admin-api/src/bin/crank-migrate.rs b/apps/admin-api/src/bin/crank-migrate.rs index 1e4eaaa..49e1dab 100644 --- a/apps/admin-api/src/bin/crank-migrate.rs +++ b/apps/admin-api/src/bin/crank-migrate.rs @@ -1,15 +1,19 @@ -use std::{process::ExitCode, time::Duration}; +#[path = "crank_migrate/admin_auth_command.rs"] +mod admin_auth_command; +#[path = "crank_migrate/db_connect.rs"] +mod db_connect; -use crank_config::{ConfigSource, DatabaseSettings, parse_migrator}; +use crank_config::{ConfigSource, parse_migrator}; use crank_registry::{ - BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationPreflight, + BackfillPolicy, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, + MasterKeyRotationRecord, MigrationApplyResult, MigrationAuthority, MigrationPreflight, + PostgresRegistry, RegistryError, SecretVersionRecord, }; +use crank_runtime::SecretCrypto; use serde_json::json; -use sqlx::{ - PgPool, - postgres::{PgConnectOptions, PgPoolOptions}, -}; - +use std::{path::Path, process::ExitCode}; +use time::OffsetDateTime; +const MASTER_KEY_ROTATION_PAGE_SIZE: i64 = 1_000; #[tokio::main] async fn main() -> ExitCode { match run().await { @@ -37,7 +41,6 @@ struct CliError { recovery: &'static str, version: Option, } - impl CliError { const fn new(code: &'static str, stage: &'static str, recovery: &'static str) -> Self { Self { @@ -47,7 +50,6 @@ impl CliError { version: None, } } - fn from_migration(error: crank_registry::MigrationError) -> Self { Self { code: error.code(), @@ -56,20 +58,87 @@ impl CliError { version: error.version(), } } + fn from_registry(error: RegistryError) -> Self { + match error { + RegistryError::MasterKeyIdentityMismatch { .. } => Self::new( + "master_key_identity_mismatch", + "master_key.identity", + "use_matching_master_key", + ), + RegistryError::InvalidMasterKeyIdentity => Self::new( + "master_key_identity_invalid", + "master_key.identity", + "verify_operator_input", + ), + RegistryError::MasterKeyRotationInProgress => Self::new( + "master_key_rotation_in_progress", + "master_key.rotation", + "resume_verify_promote_or_abort_rotation", + ), + RegistryError::MasterKeyRotationNotFound { .. } => Self::new( + "master_key_rotation_not_found", + "master_key.rotation", + "run_status", + ), + RegistryError::MasterKeyRotationConflict => Self::new( + "master_key_rotation_conflict", + "master_key.rotation", + "run_status", + ), + RegistryError::MasterKeyRotationVerificationFailed => Self::new( + "master_key_rotation_verification_failed", + "master_key.rotation", + "rerun_rotation_or_abort", + ), + RegistryError::AdminBootstrapUnavailable => Self::new( + "admin_bootstrap_unavailable", + "admin_auth.bootstrap", + "use_existing_active_contract_or_wait_until_expired", + ), + RegistryError::AdminBootstrapRejected => Self::new( + "admin_bootstrap_rejected", + "admin_auth.bootstrap", + "create_new_local_bootstrap_contract", + ), + RegistryError::AdminRecoveryRejected => Self::new( + "admin_recovery_rejected", + "admin_auth.recovery", + "verify_local_inputs_and_master_key", + ), + RegistryError::AdminLoginRateLimited { .. } => Self::new( + "admin_login_rate_limited", + "admin_auth.login", + "retry_after_delay", + ), + RegistryError::AdminCsrfRejected => { + Self::new("admin_csrf_rejected", "admin_auth.csrf", "refresh_session") + } + RegistryError::Storage(_) => { + Self::new("storage_unavailable", "database.query", "contact_operator") + } + _ => Self::new("registry_error", "registry.operation", "contact_operator"), + } + } } async fn run() -> Result { - let mut arguments = std::env::args().skip(1); - let requested = arguments.next(); - let option = arguments.next(); - if arguments.next().is_some() { + let args = std::env::args().skip(1).collect::>(); + if args.first().map(String::as_str) == Some("master-key") { + return run_master_key(&args[1..]).await; + } + if args.first().map(String::as_str) == Some("admin-auth") { + return admin_auth_command::run_admin_auth(&args[1..]).await; + } + let requested = args.first().map(String::as_str); + let option = args.get(1).map(String::as_str); + if args.len() > 2 { return Err(CliError::new( "invalid_command", "cli.arguments", "run_preflight", )); } - let command = match requested.as_deref() { + let command = match requested { None | Some("preflight") => "preflight", Some("plan") => "plan", Some("apply") => "apply", @@ -117,7 +186,7 @@ async fn run() -> Result { }) .collect::>(); let plan = json!({ "schema_version": 1, "sequence": sequence }); - if option.as_deref() == Some("--check") { + if option == Some("--check") { let bytes = std::fs::read("docs/schemas/migration-sequence.json") .map_err(|_| CliError::new("contract_drift", "plan.read", "contact_operator"))?; if bytes.len() > 65_536 { @@ -162,7 +231,7 @@ async fn run() -> Result { .map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?, ) .map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?; - let pool = connect(&config.database).await?; + let pool = db_connect::connect(&config.database).await?; if command == "apply" { let result = MigrationAuthority::apply(&pool) @@ -204,39 +273,680 @@ async fn run() -> Result { } } -async fn connect(config: &DatabaseSettings) -> Result { - let options = if let Some(url) = &config.url { - url.expose_secret() - .parse::() - .map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))? - } else { - PgConnectOptions::new() - .host(&config.host) - .port(config.port) - .database(&config.database) - .username(&config.username) - .password(config.password.expose_secret()) +async fn run_master_key(arguments: &[String]) -> Result { + let Some(command) = arguments.first().map(String::as_str) else { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_master_key_status", + )); }; - for attempt in 1..=10 { - let result = PgPoolOptions::new() - .max_connections(config.pool.max_connections) - .min_connections(config.pool.min_connections) - .acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms)) - .idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms)) - .max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms)) - .connect_with(options.clone()) - .await; - match result { - Ok(pool) => return Ok(pool), - Err(_) if attempt < 10 => { - tokio::time::sleep(Duration::from_secs(1)).await; + let options = MasterKeyOptions::parse(&arguments[1..])?; + let config = parse_migrator( + ConfigSource::from_os_for_migrator() + .map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?, + ) + .map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?; + let registry = db_connect::connect_registry(&config.database).await?; + + match command { + "status" => { + ensure_no_options(&options)?; + let status = registry + .master_key_rotation_status() + .await + .map_err(CliError::from_registry)?; + println!( + "{}", + json!({ + "status": "ok", + "active_epoch": status.active_identity.as_ref().map(|identity| identity.epoch), + "rotation_count": status.rotations.len(), + "rotations": status.rotations.iter().map(rotation_json).collect::>(), + }) + ); + } + "preflight" => { + let preflight = master_key_preflight(®istry, &options).await?; + println!( + "{}", + json!({ + "status": "preflight_ok", + "source_epoch": preflight.source_epoch, + "target_epoch": preflight.target_epoch, + "affected_secret_versions": preflight.affected_secret_versions, + "backup_ref": preflight.backup_ref.map(|_| "configured"), + }) + ); + } + "rotate" => { + let (rotation, current_crypto, target_crypto) = + if let Some(rotation) = active_rotation(®istry, &["running"]).await? { + let (current_crypto, target_crypto) = + rotation_crypto_for_resume(®istry, &options, &rotation).await?; + (rotation, current_crypto, target_crypto) + } else { + let preflight = master_key_preflight(®istry, &options).await?; + let now = OffsetDateTime::now_utc(); + let rotation = registry + .begin_master_key_rotation( + preflight.source_epoch, + preflight.target_epoch, + preflight.target_crypto.master_key_fingerprint(), + preflight.backup_ref.as_deref(), + &now, + ) + .await + .map_err(CliError::from_registry)?; + (rotation, preflight.current_crypto, preflight.target_crypto) + }; + let processed = process_rotation_batch( + ®istry, + &rotation, + ¤t_crypto, + &target_crypto, + options.max_versions, + ) + .await?; + let status = if rotation.processed_secret_versions + processed + >= rotation.total_secret_versions + { + let now = OffsetDateTime::now_utc(); + registry + .finish_master_key_rotation_batches(&rotation.id, &now) + .await + .map_err(CliError::from_registry)? + } else { + registry + .master_key_rotation_status() + .await + .map_err(CliError::from_registry)? + .rotations + .into_iter() + .find(|candidate| candidate.id == rotation.id) + .ok_or_else(|| { + CliError::new( + "master_key_rotation_not_found", + "master_key.rotation", + "run_status", + ) + })? + }; + println!( + "{}", + json!({ + "status": status.state, + "rotation_id": status.id, + "source_epoch": status.source_epoch, + "target_epoch": status.target_epoch, + "processed_secret_versions": status.processed_secret_versions, + "total_secret_versions": status.total_secret_versions, + }) + ); + } + "verify" => { + let target_key = read_required_key_file(options.target_key_file.as_deref())?; + let rotation = active_rotation(®istry, &["verifying"]) + .await? + .ok_or_else(|| { + CliError::new( + "master_key_rotation_conflict", + "master_key.rotation", + "run_rotate", + ) + })?; + let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch) + .map_err(|_| { + CliError::new( + "master_key_invalid", + "master_key.input", + "verify_operator_input", + ) + })?; + if target_crypto.master_key_fingerprint() != rotation.target_fingerprint { + return Err(CliError::new( + "master_key_identity_mismatch", + "master_key.identity", + "use_matching_target_key", + )); } - Err(_) => break, + let verified = verify_staged_targets(®istry, &rotation, &target_crypto).await?; + let now = OffsetDateTime::now_utc(); + let status = registry + .verify_master_key_rotation(&rotation.id, verified, &now) + .await + .map_err(CliError::from_registry)?; + println!( + "{}", + json!({ + "status": status.state, + "rotation_id": status.id, + "verified_secret_versions": status.verified_secret_versions, + "total_secret_versions": status.total_secret_versions, + }) + ); + } + "promote" => { + let target_key = read_required_key_file(options.target_key_file.as_deref())?; + let rotation = active_rotation(®istry, &["verified"]) + .await? + .ok_or_else(|| { + CliError::new( + "master_key_rotation_conflict", + "master_key.rotation", + "run_verify", + ) + })?; + let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch) + .map_err(|_| { + CliError::new( + "master_key_invalid", + "master_key.input", + "verify_operator_input", + ) + })?; + if target_crypto.master_key_fingerprint() != rotation.target_fingerprint { + return Err(CliError::new( + "master_key_identity_mismatch", + "master_key.identity", + "use_matching_target_key", + )); + } + verify_staged_targets(®istry, &rotation, &target_crypto).await?; + let now = OffsetDateTime::now_utc(); + let status = registry + .promote_master_key_rotation( + &rotation.id, + MasterKeyIdentityCandidate { + epoch: rotation.target_epoch, + fingerprint: target_crypto.master_key_fingerprint(), + cipher_contract: MASTER_KEY_CIPHER_CONTRACT, + observed_at: &now, + }, + &now, + ) + .await + .map_err(CliError::from_registry)?; + println!( + "{}", + json!({ + "status": status.state, + "rotation_id": status.id, + "active_epoch": status.target_epoch, + }) + ); + } + "abort" => { + let rotation_id = options + .rotation_id + .as_deref() + .ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "run_status"))?; + let now = OffsetDateTime::now_utc(); + let status = registry + .abort_master_key_rotation(rotation_id, &now) + .await + .map_err(CliError::from_registry)?; + println!( + "{}", + json!({ + "status": status.state, + "rotation_id": status.id, + "active_epoch": status.source_epoch, + }) + ); + } + _ => { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_master_key_status", + )); } } - Err(CliError::new( - "storage_unavailable", - "database.connect", - "contact_operator", - )) + Ok(ExitCode::SUCCESS) +} +#[derive(Default)] +struct MasterKeyOptions { + current_key_file: Option, + target_key_file: Option, + backup_ref: Option, + rotation_id: Option, + max_versions: Option, +} +impl MasterKeyOptions { + fn parse(arguments: &[String]) -> Result { + let mut options = Self::default(); + let mut index = 0; + while index < arguments.len() { + let key = arguments[index].as_str(); + let Some(value) = arguments.get(index + 1) else { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_master_key_status", + )); + }; + match key { + "--current-key-file" => options.current_key_file = Some(value.clone()), + "--target-key-file" => options.target_key_file = Some(value.clone()), + "--backup-ref" => options.backup_ref = Some(value.clone()), + "--rotation-id" => options.rotation_id = Some(value.clone()), + "--max-versions" => { + let parsed = value.parse::().map_err(|_| { + CliError::new("invalid_command", "cli.arguments", "run_master_key_status") + })?; + if parsed == 0 || parsed > 10_000 { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_master_key_status", + )); + } + options.max_versions = Some(parsed); + } + _ => { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_master_key_status", + )); + } + } + index += 2; + } + Ok(options) + } +} + +struct MasterKeyPreflight { + source_epoch: i64, + target_epoch: i64, + affected_secret_versions: usize, + backup_ref: Option, + current_crypto: SecretCrypto, + target_crypto: SecretCrypto, +} + +async fn master_key_preflight( + registry: &PostgresRegistry, + options: &MasterKeyOptions, +) -> Result { + let current_key = read_required_key_file(options.current_key_file.as_deref())?; + let target_key = read_required_key_file(options.target_key_file.as_deref())?; + let status = registry + .master_key_rotation_status() + .await + .map_err(CliError::from_registry)?; + if status.rotations.iter().any(|rotation| { + matches!( + rotation.state.as_str(), + "running" | "verifying" | "verified" + ) + }) { + return Err(CliError::new( + "master_key_rotation_in_progress", + "master_key.rotation", + "resume_verify_promote_or_abort_rotation", + )); + } + let active = status.active_identity.ok_or_else(|| { + CliError::new( + "master_key_identity_missing", + "master_key.identity", + "start_secret_process_once", + ) + })?; + let current_crypto = SecretCrypto::with_epoch(¤t_key, active.epoch).map_err(|_| { + CliError::new( + "master_key_invalid", + "master_key.input", + "verify_operator_input", + ) + })?; + if current_crypto.master_key_fingerprint() != active.fingerprint { + return Err(CliError::new( + "master_key_identity_mismatch", + "master_key.identity", + "use_matching_current_key", + )); + } + let target_epoch = active.epoch + 1; + let target_crypto = SecretCrypto::with_epoch(&target_key, target_epoch).map_err(|_| { + CliError::new( + "master_key_invalid", + "master_key.input", + "verify_operator_input", + ) + })?; + if target_crypto.master_key_fingerprint() == current_crypto.master_key_fingerprint() + || registry + .master_key_fingerprint_exists(target_crypto.master_key_fingerprint()) + .await + .map_err(CliError::from_registry)? + { + return Err(CliError::new( + "master_key_rotation_conflict", + "master_key.rotation", + "choose_new_target_key", + )); + } + let affected_secret_versions = + count_and_verify_current_versions(registry, active.epoch, ¤t_crypto).await?; + Ok(MasterKeyPreflight { + source_epoch: active.epoch, + target_epoch, + affected_secret_versions, + backup_ref: options.backup_ref.clone(), + current_crypto, + target_crypto, + }) +} + +async fn rotation_crypto_for_resume( + registry: &PostgresRegistry, + options: &MasterKeyOptions, + rotation: &MasterKeyRotationRecord, +) -> Result<(SecretCrypto, SecretCrypto), CliError> { + let current_key = read_required_key_file(options.current_key_file.as_deref())?; + let target_key = read_required_key_file(options.target_key_file.as_deref())?; + let active = registry + .active_master_key_identity() + .await + .map_err(CliError::from_registry)? + .ok_or_else(|| { + CliError::new( + "master_key_identity_missing", + "master_key.identity", + "start_secret_process_once", + ) + })?; + let current_crypto = + SecretCrypto::with_epoch(¤t_key, rotation.source_epoch).map_err(|_| { + CliError::new( + "master_key_invalid", + "master_key.input", + "verify_operator_input", + ) + })?; + if active.epoch != rotation.source_epoch + || active.fingerprint != current_crypto.master_key_fingerprint() + { + return Err(CliError::new( + "master_key_identity_mismatch", + "master_key.identity", + "use_matching_current_key", + )); + } + let target_crypto = + SecretCrypto::with_epoch(&target_key, rotation.target_epoch).map_err(|_| { + CliError::new( + "master_key_invalid", + "master_key.input", + "verify_operator_input", + ) + })?; + if target_crypto.master_key_fingerprint() != rotation.target_fingerprint { + return Err(CliError::new( + "master_key_identity_mismatch", + "master_key.identity", + "use_matching_target_key", + )); + } + Ok((current_crypto, target_crypto)) +} + +async fn process_rotation_batch( + registry: &PostgresRegistry, + rotation: &MasterKeyRotationRecord, + current_crypto: &SecretCrypto, + target_crypto: &SecretCrypto, + max_versions: Option, +) -> Result { + let mut processed = 0_i64; + let mut after_secret_id: Option = None; + let mut after_version: Option = None; + loop { + let versions = registry + .list_secret_versions_for_master_key_epoch_page( + rotation.source_epoch, + after_secret_id.as_deref(), + after_version, + MASTER_KEY_ROTATION_PAGE_SIZE, + ) + .await + .map_err(CliError::from_registry)?; + if versions.is_empty() { + break; + } + for version in versions { + after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned()); + after_version = Some(version.secret_version.version); + if version.target_master_key_epoch == Some(rotation.target_epoch) { + continue; + } + if max_versions.is_some_and(|limit| processed as usize >= limit) { + return Ok(processed); + } + let plaintext = decrypt_current(&version, current_crypto)?; + let target_ciphertext = target_crypto.encrypt(&plaintext).map_err(|_| { + CliError::new( + "master_key_rotation_verification_failed", + "master_key.rotation", + "rerun_rotation_or_abort", + ) + })?; + let now = OffsetDateTime::now_utc(); + registry + .stage_master_key_rotation_ciphertext( + &rotation.id, + &version.secret_version.secret_id, + version.secret_version.version, + rotation.source_epoch, + &target_ciphertext, + target_crypto.key_version(), + rotation.target_epoch, + &now, + ) + .await + .map_err(CliError::from_registry)?; + processed += 1; + } + } + Ok(processed) +} + +async fn verify_staged_targets( + registry: &PostgresRegistry, + rotation: &MasterKeyRotationRecord, + target_crypto: &SecretCrypto, +) -> Result { + let mut verified = 0_i64; + let mut after_secret_id: Option = None; + let mut after_version: Option = None; + loop { + let versions = registry + .list_target_secret_versions_for_master_key_rotation_page( + rotation.target_epoch, + after_secret_id.as_deref(), + after_version, + MASTER_KEY_ROTATION_PAGE_SIZE, + ) + .await + .map_err(CliError::from_registry)?; + if versions.is_empty() { + break; + } + for version in versions { + after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned()); + after_version = Some(version.secret_version.version); + let ciphertext = version.target_ciphertext.as_deref().ok_or_else(|| { + CliError::new( + "master_key_rotation_verification_failed", + "master_key.rotation", + "rerun_rotation_or_abort", + ) + })?; + let key_version = version.target_key_version.as_deref().ok_or_else(|| { + CliError::new( + "master_key_rotation_verification_failed", + "master_key.rotation", + "rerun_rotation_or_abort", + ) + })?; + target_crypto + .decrypt_for_epoch(key_version, rotation.target_epoch, ciphertext) + .map_err(|_| { + CliError::new( + "master_key_rotation_verification_failed", + "master_key.rotation", + "rerun_rotation_or_abort", + ) + })?; + verified += 1; + } + } + if verified != rotation.total_secret_versions { + return Err(CliError::new( + "master_key_rotation_verification_failed", + "master_key.rotation", + "rerun_rotation_or_abort", + )); + } + Ok(verified) +} + +async fn count_and_verify_current_versions( + registry: &PostgresRegistry, + epoch: i64, + current_crypto: &SecretCrypto, +) -> Result { + let mut count = 0_usize; + let mut after_secret_id: Option = None; + let mut after_version: Option = None; + loop { + let versions = registry + .list_secret_versions_for_master_key_epoch_page( + epoch, + after_secret_id.as_deref(), + after_version, + MASTER_KEY_ROTATION_PAGE_SIZE, + ) + .await + .map_err(CliError::from_registry)?; + if versions.is_empty() { + break; + } + for version in versions { + after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned()); + after_version = Some(version.secret_version.version); + decrypt_current(&version, current_crypto)?; + count += 1; + } + } + Ok(count) +} + +fn decrypt_current( + version: &SecretVersionRecord, + current_crypto: &SecretCrypto, +) -> Result { + current_crypto + .decrypt_for_epoch( + &version.secret_version.key_version, + version.master_key_epoch, + &version.secret_version.ciphertext, + ) + .map_err(|_| { + CliError::new( + "master_key_rotation_verification_failed", + "master_key.rotation", + "rerun_rotation_or_abort", + ) + }) +} + +async fn active_rotation( + registry: &PostgresRegistry, + allowed_states: &[&str], +) -> Result, CliError> { + let status = registry + .master_key_rotation_status() + .await + .map_err(CliError::from_registry)?; + Ok(status + .rotations + .into_iter() + .find(|rotation| allowed_states.contains(&rotation.state.as_str()))) +} + +fn rotation_json(rotation: &MasterKeyRotationRecord) -> serde_json::Value { + json!({ + "id": rotation.id, + "source_epoch": rotation.source_epoch, + "target_epoch": rotation.target_epoch, + "state": rotation.state, + "backup_ref": rotation.backup_ref.as_ref().map(|_| "configured"), + "checkpoint_secret_id": rotation.checkpoint_secret_id, + "total_secret_versions": rotation.total_secret_versions, + "processed_secret_versions": rotation.processed_secret_versions, + "verified_secret_versions": rotation.verified_secret_versions, + "failure_code": rotation.failure_code, + }) +} + +fn read_required_key_file(path: Option<&str>) -> Result { + let path = path.ok_or_else(|| { + CliError::new("invalid_command", "cli.arguments", "run_master_key_status") + })?; + if path.len() > 512 || path.bytes().any(|byte| byte.is_ascii_control()) { + return Err(CliError::new( + "master_key_input_invalid", + "master_key.input", + "verify_operator_input", + )); + } + let metadata = std::fs::metadata(Path::new(path)).map_err(|_| { + CliError::new( + "master_key_input_invalid", + "master_key.input", + "verify_operator_input", + ) + })?; + if !metadata.is_file() || metadata.len() > 16_384 { + return Err(CliError::new( + "master_key_input_invalid", + "master_key.input", + "verify_operator_input", + )); + } + let value = std::fs::read_to_string(Path::new(path)).map_err(|_| { + CliError::new( + "master_key_input_invalid", + "master_key.input", + "verify_operator_input", + ) + })?; + if value.trim().is_empty() { + return Err(CliError::new( + "master_key_input_invalid", + "master_key.input", + "verify_operator_input", + )); + } + Ok(value) +} +fn ensure_no_options(options: &MasterKeyOptions) -> Result<(), CliError> { + if options.current_key_file.is_some() + || options.target_key_file.is_some() + || options.backup_ref.is_some() + || options.rotation_id.is_some() + || options.max_versions.is_some() + { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_master_key_status", + )); + } + Ok(()) } diff --git a/apps/admin-api/src/bin/crank_migrate/admin_auth_command.rs b/apps/admin-api/src/bin/crank_migrate/admin_auth_command.rs new file mode 100644 index 0000000..2fc4b8a --- /dev/null +++ b/apps/admin-api/src/bin/crank_migrate/admin_auth_command.rs @@ -0,0 +1,342 @@ +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use crank_community_auth::hash_password; +use crank_config::{ConfigSource, parse_migrator}; +use crank_registry::{ + CreateAdminBootstrapContractRequest, MASTER_KEY_CIPHER_CONTRACT, PostgresRegistry, + RecoverAdminPasswordRequest, +}; +use crank_runtime::SecretCrypto; +use rand::RngExt; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{ + path::{Path, PathBuf}, + process::ExitCode, +}; +use time::{Duration as TimeDuration, OffsetDateTime}; + +use super::{CliError, db_connect::connect_registry}; + +pub(super) async fn run_admin_auth(arguments: &[String]) -> Result { + let Some(command) = arguments.first().map(String::as_str) else { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_admin_auth_bootstrap_create", + )); + }; + let options = AdminAuthOptions::parse(&arguments[1..])?; + let config = parse_migrator( + ConfigSource::from_os_for_migrator() + .map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?, + ) + .map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?; + let registry = connect_registry(&config.database).await?; + match command { + "bootstrap-create" => create_bootstrap_contract(®istry, options).await?, + "bootstrap-complete" => complete_bootstrap_contract(®istry, options).await?, + "recover" => recover_admin_password(®istry, options).await?, + _ => { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_admin_auth_bootstrap_create", + )); + } + } + Ok(ExitCode::SUCCESS) +} + +async fn complete_bootstrap_contract( + registry: &PostgresRegistry, + options: AdminAuthOptions, +) -> Result<(), CliError> { + let token_path = options + .token_file + .as_deref() + .ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_token_file"))?; + let password_path = options.password_file.as_deref().ok_or_else(|| { + CliError::new("invalid_command", "cli.arguments", "provide_password_file") + })?; + let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| { + CliError::new( + "invalid_command", + "cli.arguments", + "provide_password_pepper_file", + ) + })?; + let token = read_secret_file(token_path, "admin_auth.bootstrap_token")?; + let password = read_secret_file(password_path, "admin_auth.password")?; + let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?; + if !(32..=256).contains(&token.len()) + || !(12..=256).contains(&password.len()) + || pepper.is_empty() + || pepper.len() > 1_024 + { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "provide_bounded_secret_files", + )); + } + + let token_hash = admin_auth_hash("bootstrap", &token); + let password_hash = hash_password(&password, &pepper).map_err(|_| { + CliError::new( + "admin_bootstrap_rejected", + "admin_auth.bootstrap", + "verify_local_inputs", + ) + })?; + let now = OffsetDateTime::now_utc(); + let user_id = registry + .consume_admin_bootstrap_contract(crank_registry::ConsumeAdminBootstrapContractRequest { + token_hash: &token_hash, + password_hash: &password_hash, + now: &now, + }) + .await + .map_err(CliError::from_registry)?; + println!( + "{}", + json!({ + "status": "bootstrap_completed", + "user_id": user_id.as_str() + }) + ); + Ok(()) +} + +async fn recover_admin_password( + registry: &PostgresRegistry, + options: AdminAuthOptions, +) -> Result<(), CliError> { + let email = options + .email + .as_deref() + .ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?; + let password_path = options.password_file.as_deref().ok_or_else(|| { + CliError::new("invalid_command", "cli.arguments", "provide_password_file") + })?; + let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| { + CliError::new( + "invalid_command", + "cli.arguments", + "provide_password_pepper_file", + ) + })?; + let master_key_path = options.master_key_file.as_deref().ok_or_else(|| { + CliError::new( + "invalid_command", + "cli.arguments", + "provide_master_key_file", + ) + })?; + + let master_key = read_secret_file(master_key_path, "master_key.input")?; + let active = registry + .active_master_key_identity() + .await + .map_err(CliError::from_registry)? + .ok_or_else(|| { + CliError::new( + "master_key_identity_missing", + "master_key.identity", + "start_service_once_with_current_master_key", + ) + })?; + let crypto = SecretCrypto::with_epoch(&master_key, active.epoch).map_err(|_| { + CliError::new( + "master_key_invalid", + "master_key.input", + "provide_current_master_key_file", + ) + })?; + if active.cipher_contract != MASTER_KEY_CIPHER_CONTRACT + || active.fingerprint != crypto.master_key_fingerprint() + { + return Err(CliError::new( + "master_key_identity_mismatch", + "master_key.identity", + "use_matching_master_key", + )); + } + + let password = read_secret_file(password_path, "admin_auth.password")?; + let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?; + if !(12..=256).contains(&password.len()) || pepper.is_empty() || pepper.len() > 1_024 { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "provide_bounded_secret_files", + )); + } + let password_hash = hash_password(&password, &pepper).map_err(|_| { + CliError::new( + "admin_recovery_rejected", + "admin_auth.recovery", + "verify_local_inputs", + ) + })?; + let audit_id = format!("audit_{}", uuid::Uuid::now_v7().simple()); + let user_id = registry + .recover_admin_password(RecoverAdminPasswordRequest { + email, + password_hash: &password_hash, + audit_id: &audit_id, + }) + .await + .map_err(CliError::from_registry)?; + println!( + "{}", + json!({ + "status": "admin_recovered", + "user_id": user_id.as_str(), + "sessions_revoked": true, + "audit_id": audit_id + }) + ); + Ok(()) +} + +async fn create_bootstrap_contract( + registry: &PostgresRegistry, + options: AdminAuthOptions, +) -> Result<(), CliError> { + let email = options + .email + .as_deref() + .ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?; + let display_name = options.display_name.as_deref().unwrap_or("Crank Owner"); + let ttl_seconds = options.ttl_seconds.unwrap_or(900); + if !(60..=86_400).contains(&ttl_seconds) { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "set_ttl_between_60_and_86400", + )); + } + let token = random_token(); + let contract_id = format!("boot_{}", uuid::Uuid::now_v7().simple()); + let token_hash = admin_auth_hash("bootstrap", &token); + let now = OffsetDateTime::now_utc(); + let expires_at = now + .checked_add(TimeDuration::seconds(ttl_seconds)) + .ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "reduce_ttl"))?; + let contract = registry + .create_admin_bootstrap_contract(CreateAdminBootstrapContractRequest { + id: &contract_id, + token_hash: &token_hash, + email, + display_name, + expires_at: &expires_at, + }) + .await + .map_err(CliError::from_registry)?; + println!( + "{}", + json!({ + "status": "bootstrap_created", + "contract_id": contract.id, + "expires_at": contract.expires_at, + "bootstrap_token": token, + "warning": "copy_once_token_not_logged_by_services" + }) + ); + Ok(()) +} + +#[derive(Default)] +struct AdminAuthOptions { + email: Option, + display_name: Option, + ttl_seconds: Option, + token_file: Option, + password_file: Option, + password_pepper_file: Option, + master_key_file: Option, +} + +impl AdminAuthOptions { + fn parse(arguments: &[String]) -> Result { + let mut options = Self::default(); + let mut index = 0; + while index < arguments.len() { + let key = arguments[index].as_str(); + let Some(value) = arguments.get(index + 1) else { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_admin_auth_bootstrap_create", + )); + }; + match key { + "--email" => options.email = Some(value.clone()), + "--display-name" => options.display_name = Some(value.clone()), + "--ttl-seconds" => { + options.ttl_seconds = Some(value.parse::().map_err(|_| { + CliError::new("invalid_command", "cli.arguments", "set_ttl_seconds") + })?); + } + "--password-file" => options.password_file = Some(PathBuf::from(value)), + "--token-file" => options.token_file = Some(PathBuf::from(value)), + "--password-pepper-file" => { + options.password_pepper_file = Some(PathBuf::from(value)); + } + "--master-key-file" => options.master_key_file = Some(PathBuf::from(value)), + _ => { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_admin_auth_bootstrap_create", + )); + } + } + index += 2; + } + Ok(options) + } +} + +fn read_secret_file(path: &Path, stage: &'static str) -> Result { + let metadata = std::fs::metadata(path) + .map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?; + if !metadata.is_file() || metadata.len() > 8_192 { + return Err(CliError::new( + "invalid_command", + stage, + "provide_bounded_secret_file", + )); + } + let bytes = std::fs::read(path) + .map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?; + if bytes.len() > 8_192 { + return Err(CliError::new( + "invalid_command", + stage, + "provide_bounded_secret_file", + )); + } + let mut value = String::from_utf8(bytes) + .map_err(|_| CliError::new("invalid_command", stage, "provide_utf8_secret_file"))?; + if value.ends_with("\r\n") { + value.truncate(value.len() - 2); + } else if value.ends_with('\n') { + value.truncate(value.len() - 1); + } + Ok(value) +} + +fn random_token() -> String { + let mut bytes = [0_u8; 32]; + rand::rng().fill(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn admin_auth_hash(scope: &str, token: &str) -> String { + let mut digest = Sha256::new(); + digest.update(scope.as_bytes()); + digest.update(b":"); + digest.update(token.as_bytes()); + URL_SAFE_NO_PAD.encode(digest.finalize()) +} diff --git a/apps/admin-api/src/bin/crank_migrate/db_connect.rs b/apps/admin-api/src/bin/crank_migrate/db_connect.rs new file mode 100644 index 0000000..ea08ea1 --- /dev/null +++ b/apps/admin-api/src/bin/crank_migrate/db_connect.rs @@ -0,0 +1,78 @@ +use crank_config::DatabaseSettings; +use crank_registry::{PostgresPoolConfig, PostgresRegistry, RegistryError}; +use sqlx::{ + PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, +}; +use std::time::Duration; + +use super::CliError; + +pub(super) async fn connect(config: &DatabaseSettings) -> Result { + let options = connect_options(config)?; + for attempt in 1..=10 { + let result = PgPoolOptions::new() + .max_connections(config.pool.max_connections) + .min_connections(config.pool.min_connections) + .acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms)) + .idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms)) + .max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms)) + .connect_with(options.clone()) + .await; + match result { + Ok(pool) => return Ok(pool), + Err(_) if attempt < 10 => tokio::time::sleep(Duration::from_secs(1)).await, + Err(_) => break, + } + } + Err(CliError::new( + "storage_unavailable", + "database.connect", + "contact_operator", + )) +} + +pub(super) async fn connect_registry( + config: &DatabaseSettings, +) -> Result { + let options = connect_options(config)?; + let pool_config = PostgresPoolConfig { + max_connections: config.pool.max_connections, + min_connections: config.pool.min_connections, + acquire_timeout_ms: config.pool.acquire_timeout_ms, + idle_timeout_ms: config.pool.idle_timeout_ms, + max_lifetime_ms: config.pool.max_lifetime_ms, + }; + for attempt in 1..=10 { + let result = + PostgresRegistry::connect_with_options_and_pool_config(options.clone(), pool_config) + .await; + match result { + Ok(registry) => return Ok(registry), + Err(RegistryError::Storage(_)) if attempt < 10 => { + tokio::time::sleep(Duration::from_secs(1)).await; + } + Err(error) => return Err(CliError::from_registry(error)), + } + } + Err(CliError::new( + "storage_unavailable", + "database.connect", + "contact_operator", + )) +} + +fn connect_options(config: &DatabaseSettings) -> Result { + if let Some(url) = &config.url { + url.expose_secret() + .parse::() + .map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight")) + } else { + Ok(PgConnectOptions::new() + .host(&config.host) + .port(config.port) + .database(&config.database) + .username(&config.username) + .password(config.password.expose_secret())) + } +} diff --git a/apps/admin-api/src/dto.rs b/apps/admin-api/src/dto.rs index 9302ab5..5f77466 100644 --- a/apps/admin-api/src/dto.rs +++ b/apps/admin-api/src/dto.rs @@ -1,13 +1,15 @@ use crank_core::{ AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, - GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, - OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, - ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus, + GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationAvailability, + OperationSecurityLevel, OperationStatus, OperationVersionState, PlatformApiKeyKind, + PlatformApiKeyScope, Protocol, SecretKind, Target, ToolSelectionPolicy, UsagePeriod, + WizardState, WorkspaceId, WorkspaceStatus, }; use crank_mapping::MappingSet; use crank_registry::{ - PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown, UsageOperationBreakdown, - UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, + InvocationLogRecord, PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown, + UsageOperationBreakdown, UsageOutcomeGroup, UsageSummary, UsageTimelinePoint, + WorkspaceMembershipRecord, WorkspaceRecord, }; use crank_schema::Schema; use serde::{Deserialize, Serialize}; @@ -19,11 +21,23 @@ pub struct LoginPayload { pub password: String, } +#[derive(Clone, Debug, Deserialize)] +pub struct CompleteBootstrapPayload { + pub token: String, + pub password: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BootstrapStatusResponse { + pub bootstrap_required: bool, +} + #[derive(Clone, Debug, Serialize)] pub struct SessionResponse { pub user: crank_core::User, pub memberships: Vec, pub current_workspace_id: Option, + pub csrf_token: String, } #[derive(Clone, Debug, Deserialize)] @@ -80,12 +94,19 @@ pub struct PublishPayload { pub struct TestRunPayload { pub version: u32, pub input: Value, + #[serde(default)] + pub confirmation_token: Option, + #[serde(default)] + pub locale: Option, } #[derive(Clone, Debug, Serialize)] pub struct TestRunResult { pub ok: bool, pub mode: ExecutionMode, + pub tested_version: u32, + pub request_id: String, + pub trace_id: String, pub request_preview: Value, pub response_preview: Value, pub errors: Vec, @@ -228,6 +249,7 @@ pub struct AgentSummaryView { pub status: AgentStatus, pub current_draft_version: u32, pub latest_published_version: Option, + pub catalog_revision: i64, pub created_at: String, pub updated_at: String, pub published_at: Option, @@ -266,6 +288,91 @@ fn default_platform_api_key_kind() -> PlatformApiKeyKind { pub struct CreatedPlatformApiKeyResponse { pub api_key: PlatformApiKeyRecord, pub secret: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub connection: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct EphemeralMcpClientConfig { + pub client: String, + pub config: Value, +} + +#[derive(Clone, Debug, Serialize)] +pub struct EphemeralMcpConnection { + pub endpoint: String, + pub clients: Vec, + pub secret_display: &'static str, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OnboardingEventPayload { + pub event: String, + pub idempotency_key: String, + pub expected_revision: i64, + #[serde(default)] + pub completed_steps: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OnboardingStepView { + pub id: crank_core::OnboardingStepId, + pub completed: bool, + pub status: String, + pub action_code: String, + pub reason_code: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OnboardingFirstCallEvidence { + pub log_id: String, + pub agent_id: String, + pub key_id: String, + pub operation_id: String, + pub operation_version: u32, + pub tool_name: String, + pub occurred_at: String, + pub request_id: Option, + pub trace_id: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OnboardingResponse { + pub schema_version: u16, + pub workspace_id: String, + pub revision: i64, + pub status: String, + pub completed: bool, + pub eligible_since: Option, + pub steps: Vec, + pub operation_id: Option, + pub operation_version: Option, + pub agent_id: Option, + pub catalog_revision: Option, + pub platform_api_key_id: Option, + pub mcp_endpoint: Option, + pub first_call: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OnboardingEventResponse { + pub accepted: bool, + #[serde(flatten)] + pub onboarding: OnboardingResponse, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResetOnboardingSelectionPayload { + pub expected_revision: i64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ResetOnboardingSelectionResponse { + pub selection_reset: bool, + #[serde(flatten)] + pub onboarding: OnboardingResponse, } #[derive(Clone, Debug, Serialize)] @@ -285,11 +392,16 @@ pub struct WorkspaceCatalogSnapshotResponse { #[derive(Clone, Debug, Deserialize)] pub struct LogsQuery { pub level: Option, + pub status: Option, + pub outcome_group: Option, pub search: Option, pub source: Option, pub operation_id: Option, pub agent_id: Option, pub period: Option, + pub created_after: Option, + pub created_before: Option, + pub cursor: Option, pub limit: Option, } @@ -299,10 +411,19 @@ pub struct ApprovalsQuery { pub limit: Option, } +#[derive(Clone, Debug, Deserialize)] +pub struct ApprovalDecisionPayload { + pub approve: String, + #[serde(default)] + pub note: Option, +} + #[derive(Clone, Debug, Deserialize)] pub struct UsageRequestQuery { pub period: Option, pub source: Option, + pub created_after: Option, + pub created_before: Option, } #[derive(Clone, Debug, Serialize)] @@ -311,6 +432,13 @@ pub struct UsageOverviewResponse { pub timeline: Vec, pub operations: Vec, pub agents: Vec, + pub outcomes: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LogsListResponse { + pub items: Vec, + pub next_cursor: Option, } #[derive(Clone, Debug, Serialize)] @@ -362,7 +490,35 @@ pub struct ExportQuery { } #[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct YamlOperationDocument { + pub format_version: String, + pub kind: String, + pub operation: PortableOperation, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PortableOperation { + pub name: String, + pub display_name: String, + #[serde(default = "default_operation_category")] + pub category: String, + pub protocol: Protocol, + #[serde(default)] + pub security_level: OperationSecurityLevel, + pub target: Target, + pub input_schema: Schema, + pub output_schema: Schema, + pub input_mapping: MappingSet, + pub output_mapping: MappingSet, + pub execution_config: crank_core::ExecutionConfig, + pub tool_description: crank_core::ToolDescription, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LegacyYamlOperationDocument { pub format_version: String, pub kind: String, pub operation: RegistryOperation, @@ -473,6 +629,7 @@ pub struct OperationSummaryView { pub status: OperationStatus, pub current_draft_version: u32, pub latest_published_version: Option, + pub can_delete: bool, pub created_at: String, pub updated_at: String, pub published_at: Option, @@ -490,6 +647,7 @@ pub struct OperationDetailView { pub protocol: Protocol, pub security_level: OperationSecurityLevel, pub status: OperationStatus, + pub availability: OperationAvailability, pub current_draft_version: u32, pub latest_published_version: Option, pub created_at: String, @@ -503,7 +661,7 @@ pub struct OperationDetailView { #[derive(Clone, Debug, Serialize)] pub struct VersionRef { pub version: u32, - pub status: OperationStatus, + pub status: OperationVersionState, } #[derive(Clone, Debug, Serialize)] @@ -558,6 +716,10 @@ pub(crate) struct InvocationRecordRequest<'a> { pub message: String, pub status_code: Option, pub error_kind: Option, + pub execution_stage: Option, + pub execution_error_code: Option, + pub retryability: Option, + pub outcome_certainty: Option, pub duration_ms: u64, pub request_preview: Value, pub response_preview: Value, diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 3acb9f7..0ab28aa 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -31,6 +31,11 @@ pub enum ApiError { context: Option, }, #[error("{message}")] + Unprocessable { + message: String, + context: Option, + }, + #[error("{message}")] NotFound { message: String, context: Option, @@ -41,6 +46,16 @@ pub enum ApiError { context: Option, }, #[error("{message}")] + PayloadTooLarge { + message: String, + context: Option, + }, + #[error("{message}")] + PreconditionRequired { + message: String, + context: Option, + }, + #[error("{message}")] RateLimited { message: String, context: Option, @@ -95,6 +110,13 @@ impl ApiError { } } + pub(crate) fn unprocessable_with_context(message: impl Into, context: Value) -> Self { + Self::Unprocessable { + message: message.into(), + context: Some(context), + } + } + pub(crate) fn not_found_with_context(message: impl Into, context: Value) -> Self { Self::NotFound { message: message.into(), @@ -109,25 +131,58 @@ impl ApiError { } } + pub(crate) fn payload_too_large_with_context( + message: impl Into, + context: Value, + ) -> Self { + Self::PayloadTooLarge { + message: message.into(), + context: Some(context), + } + } + + pub(crate) fn precondition_required_with_context( + message: impl Into, + context: Value, + ) -> Self { + Self::PreconditionRequired { + message: message.into(), + context: Some(context), + } + } + fn status_code(&self) -> StatusCode { match self { Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED, Self::Forbidden { .. } => StatusCode::FORBIDDEN, Self::Validation { .. } => StatusCode::BAD_REQUEST, + Self::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY, Self::NotFound { .. } => StatusCode::NOT_FOUND, Self::Conflict { .. } => StatusCode::CONFLICT, + Self::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, + Self::PreconditionRequired { .. } => StatusCode::PRECONDITION_REQUIRED, Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS, Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } - fn code(&self) -> &'static str { + pub(crate) fn code(&self) -> &str { + if let Some(code) = self + .context_ref() + .and_then(|context| context.get("error_code")) + .and_then(Value::as_str) + { + return code; + } match self { Self::Unauthorized { .. } => "unauthorized", Self::Forbidden { .. } => "forbidden", Self::Validation { .. } => "validation_error", + Self::Unprocessable { .. } => "unprocessable_entity", Self::NotFound { .. } => "not_found", Self::Conflict { .. } => "conflict", + Self::PayloadTooLarge { .. } => "payload_too_large", + Self::PreconditionRequired { .. } => "precondition_required", Self::RateLimited { .. } => "rate_limited", Self::Internal { .. } => "internal_error", } @@ -147,8 +202,11 @@ impl IntoResponse for ApiError { Self::Unauthorized { .. } | Self::Forbidden { .. } | Self::Validation { .. } + | Self::Unprocessable { .. } | Self::NotFound { .. } | Self::Conflict { .. } + | Self::PayloadTooLarge { .. } + | Self::PreconditionRequired { .. } | Self::RateLimited { .. } => { warn!( name: "admin.response.rejected", @@ -182,17 +240,24 @@ impl IntoResponse for ApiError { } impl ApiError { - fn context(&self) -> Option { + fn context_ref(&self) -> Option<&Value> { match self { Self::Unauthorized { context, .. } | Self::Forbidden { context, .. } | Self::Validation { context, .. } + | Self::Unprocessable { context, .. } | Self::NotFound { context, .. } | Self::Conflict { context, .. } + | Self::PayloadTooLarge { context, .. } + | Self::PreconditionRequired { context, .. } | Self::RateLimited { context, .. } - | Self::Internal { context, .. } => context.clone(), + | Self::Internal { context, .. } => context.as_ref(), } } + + fn context(&self) -> Option { + self.context_ref().cloned() + } } impl From for ApiError { @@ -228,10 +293,41 @@ impl From for ApiError { format!("platform api key {key_id} was not found"), json!({ "key_id": key_id }), ), + RegistryError::PlatformApiKeyInactive { key_id } => Self::conflict_with_context( + "platform api key is not active", + json!({ + "key_id": key_id, + "error_code": "platform_api_key_not_active", + "recovery": "create_replacement_key" + }), + ), RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context( format!("secret {secret_id} was not found"), json!({ "secret_id": secret_id }), ), + RegistryError::SecretInactive { secret_id } => Self::conflict_with_context( + "secret is not active", + json!({ + "secret_id": secret_id, + "error_code": "secret_not_active", + "recovery": "rotate_or_replace_secret" + }), + ), + RegistryError::SecretConcurrentUpdate { secret_id } => Self::conflict_with_context( + "secret was updated concurrently", + json!({ + "secret_id": secret_id, + "error_code": "secret_concurrent_update", + "recovery": "reload" + }), + ), + RegistryError::MasterKeyRotationInProgress => Self::conflict_with_context( + "master key rotation is in progress", + json!({ + "error_code": "master_key_rotation_in_progress", + "recovery": "retry_after_rotation" + }), + ), RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context( format!("invocation log {log_id} was not found"), json!({ "log_id": log_id }), @@ -268,6 +364,57 @@ impl From for ApiError { json!({ "operation_id": operation_id }), ) } + RegistryError::OperationArchived { operation_id } => Self::conflict_with_context( + format!("operation {operation_id} is archived"), + json!({ "operation_id": operation_id, "error_code": "operation_archived" }), + ), + RegistryError::OperationStaleVersion { + operation_id, + expected, + actual, + } => Self::conflict_with_context( + format!("operation {operation_id} has a stale base version"), + json!({ + "operation_id": operation_id, + "current_version": expected, + "provided_version": actual, + "error_code": "operation_stale_version", + "recovery": "reload" + }), + ), + RegistryError::InvalidOperationTransition { + operation_id, + from, + action, + } => Self::conflict_with_context( + format!("operation {operation_id} cannot perform {action} from {from}"), + json!({ + "operation_id": operation_id, + "state": from, + "action": action, + "error_code": "operation_invalid_transition" + }), + ), + RegistryError::OperationDeleteForbidden { operation_id } => { + Self::conflict_with_context( + format!( + "operation {operation_id} cannot be deleted because durable history exists" + ), + json!({ + "operation_id": operation_id, + "error_code": "operation_delete_forbidden" + }), + ) + } + RegistryError::OperationAuthProfileUnavailable { operation_id } => { + Self::unprocessable_with_context( + "operation auth profile reference is unavailable", + json!({ + "operation_id": operation_id, + "error_code": "operation_auth_profile_invalid" + }), + ) + } RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context( format!("auth profile {auth_profile_id} was not found"), json!({ "auth_profile_id": auth_profile_id }), @@ -276,6 +423,17 @@ impl From for ApiError { format!("operation {operation_id} already exists"), json!({ "operation_id": operation_id }), ), + RegistryError::PlatformApiKeyNameAlreadyExists { workspace_id, name } => { + Self::conflict_with_context( + "platform api key name already exists", + json!({ + "workspace_id": workspace_id, + "name": name, + "error_code": "platform_api_key_name_conflict", + "recovery": "choose_different_name" + }), + ) + } RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context( format!("workspace with slug {slug} already exists"), json!({ "slug": slug }), @@ -286,6 +444,8 @@ impl From for ApiError { json!({ "workspace_id": workspace_id, "name": name, + "error_code": "secret_name_conflict", + "recovery": "choose_different_name" }), ) } @@ -297,12 +457,29 @@ impl From for ApiError { json!({ "secret_id": secret_id, "auth_profile_id": auth_profile_id, + "error_code": "secret_referenced_by_auth_profile", + "recovery": "remove_or_update_auth_profile_reference" }), ), RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context( format!("user with email {email} already exists"), json!({ "email": email }), ), + RegistryError::AdminBootstrapUnavailable | RegistryError::AdminBootstrapRejected => { + Self::unauthorized("bootstrap request is invalid or expired") + } + RegistryError::AdminRecoveryRejected => Self::unauthorized("recovery request rejected"), + RegistryError::AdminLoginRateLimited { retry_after_ms } => { + Self::rate_limited_with_context( + "login temporarily unavailable", + json!({ + "retry_after_ms": retry_after_ms.clamp(1, 300_000), + "error_code": "login_throttled", + "recovery": "retry_after_delay" + }), + ) + } + RegistryError::AdminCsrfRejected => Self::forbidden("csrf validation failed"), RegistryError::InvalidInitialVersion { operation_id, version, @@ -337,6 +514,39 @@ impl From for ApiError { "actual": actual, }), ), + RegistryError::ImmutableAgentVersion { agent_id, version } => { + Self::conflict_with_context( + "Agent Version is immutable; reload before editing", + json!({ + "agent_id": agent_id, + "version": version, + "error_code": "agent_stale_revision", + "recovery": "reload" + }), + ) + } + RegistryError::AgentStaleRevision { agent_id } => Self::conflict_with_context( + "Agent state changed; reload before retrying", + json!({ + "agent_id": agent_id, + "error_code": "agent_stale_revision", + "recovery": "reload" + }), + ), + RegistryError::InvalidAgentTransition { + agent_id, + from, + action, + } => Self::conflict_with_context( + format!("agent {agent_id} cannot transition from {from} using {action}"), + json!({ + "agent_id": agent_id, + "from": from, + "action": action, + "error_code": "agent_invalid_transition", + "recovery": "reload" + }), + ), RegistryError::ImmutableOperationFieldChanged { operation_id, field, @@ -347,6 +557,14 @@ impl From for ApiError { "field": field, }), ), + RegistryError::OnboardingStaleRevision => Self::conflict_with_context( + "onboarding state changed; reload before retrying", + json!({"error_code":"onboarding_stale_revision","recovery":"reload"}), + ), + RegistryError::OnboardingIncomplete => Self::unprocessable_with_context( + "onboarding domain steps are not complete", + json!({"error_code":"onboarding_incomplete"}), + ), RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context( format!("unsupported enum representation for field {field}"), json!({ "field": field }), @@ -370,10 +588,18 @@ impl From for ApiError { format!("import job {job_id} was already applied with different parameters"), json!({ "job_id": job_id }), ), + RegistryError::Storage(_) => Self::internal("registry operation failed"), RegistryError::Migration(_) - | RegistryError::Storage(_) | RegistryError::Serialization(_) - | RegistryError::InvalidCorrelationIdentity { .. } => Self::internal(value.to_string()), + | RegistryError::MasterKeyIdentityMismatch { .. } + | RegistryError::InvalidMasterKeyIdentity + | RegistryError::MasterKeyRotationNotFound { .. } + | RegistryError::MasterKeyRotationConflict + | RegistryError::MasterKeyRotationVerificationFailed + | RegistryError::InvalidCorrelationIdentity { .. } + | RegistryError::InvalidExecutionRecord { .. } => { + Self::internal("registry operation failed") + } } } } @@ -405,117 +631,34 @@ impl From for ApiError { } pub fn runtime_test_failure(error: &RuntimeError) -> Value { + let failure = + crank_runtime::normalize_runtime_error(error, &crank_core::CorrelationContext::generate()); + execution_test_failure(&failure) +} + +pub fn execution_test_failure(failure: &crank_core::ExecutionFailure) -> Value { + execution_test_failure_localized(failure, crank_core::ExecutionLocale::En) +} + +pub fn execution_test_failure_localized( + failure: &crank_core::ExecutionFailure, + locale: crank_core::ExecutionLocale, +) -> Value { let mut payload = json!({ - "code": runtime_test_failure_code(error), - "message": safe_runtime_test_failure_message(error) + "code": failure.error_code().as_str(), + "message": failure.error_code().message(locale), + "stage": failure.stage().as_str(), + "retryability": failure.retryability().as_str(), + "outcome_certainty": failure.outcome_certainty().as_str(), }); - if let Some(context) = runtime_error_context(error) { - payload["context"] = context; + if let Some(status) = failure.upstream_status() { + payload["context"] = json!({ "upstream_status": status }); + } + if let Some(challenge) = failure.confirmation() { + payload["context"] = json!({ + "confirmation_token": challenge.token(), + "expires_in_ms": challenge.expires_in_ms(), + }); } payload } - -fn safe_runtime_test_failure_message(error: &RuntimeError) -> &'static str { - match error { - RuntimeError::Schema(_) => "input schema validation failed", - RuntimeError::Mapping(_) => "input mapping failed", - RuntimeError::RestAdapter(_) | RuntimeError::ProtocolAdapter(_) => { - "upstream execution failed" - } - RuntimeError::UnsupportedProtocol { .. } => "operation protocol is unsupported", - RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime concurrency limit exceeded", - RuntimeError::InvalidPreparedRequest { .. } => "prepared request is invalid", - RuntimeError::ConfirmationRequired { .. } => "operation confirmation is required", - RuntimeError::InvalidConfirmationToken { .. } => "confirmation token is invalid", - RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation store is unavailable", - RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency store is unavailable", - RuntimeError::IdempotencyInProgress { .. } => "idempotent execution is in progress", - RuntimeError::IdempotencyConflict { .. } => "idempotency key conflicts with the request", - RuntimeError::IdempotencyOutcomeUnknown { .. } => "previous execution outcome is unknown", - RuntimeError::UnsupportedExecutionMode { .. } => "execution mode is unsupported", - RuntimeError::MissingAuthProfile { .. } => "authorization profile is missing", - RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { - "authorization secret is missing" - } - RuntimeError::InvalidAuthSecretValue { .. } => "authorization secret is invalid", - RuntimeError::SecretCrypto { .. } => "authorization secret processing failed", - } -} - -fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { - match error { - RuntimeError::Schema(_) => "runtime_schema_error", - RuntimeError::Mapping(_) => "runtime_mapping_error", - RuntimeError::RestAdapter(_) => "runtime_rest_error", - RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error", - RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error", - RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded", - RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error", - RuntimeError::ConfirmationRequired { .. } => "runtime_confirmation_required", - RuntimeError::InvalidConfirmationToken { .. } => "runtime_confirmation_error", - RuntimeError::ConfirmationStoreUnavailable { .. } => "runtime_confirmation_unavailable", - RuntimeError::IdempotencyStoreUnavailable { .. } => "runtime_idempotency_unavailable", - RuntimeError::IdempotencyInProgress { .. } => "runtime_idempotency_in_progress", - RuntimeError::IdempotencyConflict { .. } => "runtime_idempotency_conflict", - RuntimeError::IdempotencyOutcomeUnknown { .. } => "runtime_idempotency_outcome_unknown", - RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error", - RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error", - RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { - "runtime_secret_error" - } - RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error", - RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error", - } -} - -pub fn runtime_error_context(error: &RuntimeError) -> Option { - match error { - RuntimeError::InvalidPreparedRequest { field, .. } => Some(json!({ - "field": field, - })), - RuntimeError::ConfirmationRequired { - confirmation_token, - expires_in_ms, - safety_class, - .. - } => Some(json!({ - "confirmation_token": confirmation_token, - "expires_in_ms": expires_in_ms, - "safety_class": safety_class, - })), - RuntimeError::InvalidConfirmationToken { operation_id } - | RuntimeError::ConfirmationStoreUnavailable { operation_id } - | RuntimeError::IdempotencyStoreUnavailable { operation_id } - | RuntimeError::IdempotencyInProgress { operation_id } - | RuntimeError::IdempotencyConflict { operation_id } - | RuntimeError::IdempotencyOutcomeUnknown { operation_id } => Some(json!({ - "operation_id": operation_id, - })), - RuntimeError::InvalidAuthSecretValue { secret_id, .. } => Some(json!({ - "secret_id": secret_id, - })), - RuntimeError::SecretCrypto { .. } => None, - RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({ - "auth_profile_id": auth_profile_id, - })), - RuntimeError::MissingSecret { secret_id } => Some(json!({ - "secret_id": secret_id, - })), - RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({ - "secret_id": secret_id, - "version": version, - })), - RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({ - "operation_id": operation_id, - "mode": mode, - })), - RuntimeError::UnsupportedProtocol { protocol } => Some(json!({ - "protocol": protocol, - })), - RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({ - "kind": kind, - "limit": limit, - })), - _ => None, - } -} diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 86f0886..9d0e37c 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -17,7 +17,9 @@ use crank_observability::{ CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle, OtlpTraceConfig, RedactionLimits, SentryConfig, ServiceIdentity, capture_critical_error, }; -use crank_registry::{PostgresPoolConfig, PostgresRegistry}; +use crank_registry::{ + MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresPoolConfig, PostgresRegistry, +}; use crank_runtime::{ RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores, RuntimeLimits, SecretCrypto, @@ -65,6 +67,36 @@ fn safe_startup_diagnostic(error: &(dyn std::error::Error + 'static)) -> String }) .to_string(); } + if let Some(crank_registry::RegistryError::MasterKeyIdentityMismatch { epoch }) = + cause.downcast_ref::() + { + return serde_json::json!({ + "status": "error", + "code": "master_key_identity_mismatch", + "stage": "startup.master_key_identity", + "version": epoch, + "recovery": "configure_same_master_key", + }) + .to_string(); + } + if cause + .downcast_ref::() + .is_some_and(|error| { + matches!( + error, + crank_registry::RegistryError::InvalidMasterKeyIdentity + ) + }) + { + return serde_json::json!({ + "status": "error", + "code": "master_key_identity_invalid", + "stage": "startup.master_key_identity", + "version": null, + "recovery": "contact_operator", + }) + .to_string(); + } current = cause.source(); } serde_json::json!({ @@ -149,7 +181,11 @@ async fn run( cookie_secure: base_url.starts_with("https://"), bootstrap_admin: BootstrapAdminConfig { email: config.bootstrap_email.clone(), - password: config.bootstrap_password.expose_secret().to_owned(), + password: config + .bootstrap_password + .as_ref() + .map(|password| password.expose_secret().to_owned()) + .unwrap_or_default(), display_name: config.bootstrap_display_name.clone(), }, }; @@ -163,10 +199,13 @@ async fn run( config.rate_limit.requests_per_second, config.rate_limit.burst, )?; - let secret_crypto = SecretCrypto::new(config.runtime.master_key.expose_secret())?; - let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new( + let secret_crypto = + verified_startup_secret_crypto(®istry, config.runtime.master_key.expose_secret()) + .await?; + let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new_with_limits( config.runtime.outbound.allowed_hosts.clone(), config.runtime.outbound.denied_hosts.clone(), + config.runtime.outbound.max_request_bytes, config.runtime.outbound.max_response_bytes, )?; let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone()) @@ -183,10 +222,10 @@ async fn run( secret_crypto, runtime, ) + .with_public_base_url(base_url) .with_outbound_http_policy(outbound_http_policy) .with_identity_provider(std::sync::Arc::new(identity_provider)) .build(); - service.bootstrap_admin_user().await?; if config.demo_seed { service.seed_demo_assets().await?; } @@ -198,7 +237,7 @@ async fn run( } else { RequestRateLimiter::new(api_rate_limit) }, - trust_forwarded_headers: config.trust_forwarded_headers, + trusted_proxy_ips: config.trusted_proxy_ips.clone(), }; let app = build_app(state); let listener = TcpListener::bind(config.bind_addr).await?; @@ -285,9 +324,10 @@ fn preflight_config(config: &AdminProcessConfig) -> Result<(), crank_config::Con .map_err(|_| invalid("admin.rate_limit"))?; SecretCrypto::new(config.runtime.master_key.expose_secret()) .map_err(|_| invalid("runtime.master_key"))?; - crank_runtime::OutboundHttpPolicy::try_new( + crank_runtime::OutboundHttpPolicy::try_new_with_limits( config.runtime.outbound.allowed_hosts.clone(), config.runtime.outbound.denied_hosts.clone(), + config.runtime.outbound.max_request_bytes, config.runtime.outbound.max_response_bytes, ) .map_err(|_| invalid("runtime.outbound"))?; @@ -315,6 +355,53 @@ fn preflight_config(config: &AdminProcessConfig) -> Result<(), crank_config::Con Ok(()) } +async fn verified_startup_secret_crypto( + registry: &PostgresRegistry, + master_key: &str, +) -> Result> { + let active = registry.active_master_key_identity().await?; + let secret_crypto = if let Some(identity) = active { + SecretCrypto::with_epoch(master_key, identity.epoch)? + } else { + let crypto = SecretCrypto::new(master_key)?; + let mut after_secret_id: Option = None; + let mut after_version: Option = None; + loop { + let versions = registry + .list_secret_versions_for_master_key_epoch_page( + 1, + after_secret_id.as_deref(), + after_version, + 1_000, + ) + .await?; + if versions.is_empty() { + break; + } + for version in versions { + after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned()); + after_version = Some(version.secret_version.version); + crypto.decrypt_for_epoch( + &version.secret_version.key_version, + version.master_key_epoch, + &version.secret_version.ciphertext, + )?; + } + } + crypto + }; + let master_key_observed_at = time::OffsetDateTime::now_utc(); + registry + .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { + epoch: secret_crypto.master_key_epoch(), + fingerprint: secret_crypto.master_key_fingerprint(), + cipher_contract: MASTER_KEY_CIPHER_CONTRACT, + observed_at: &master_key_observed_at, + }) + .await?; + Ok(secret_crypto) +} + fn otlp_config( config: &ObservabilitySettings, ) -> Result { @@ -395,12 +482,23 @@ fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, reten interval.tick().await; let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days); match service.cleanup_invocation_logs_before(cutoff).await { - Ok(removed) if removed > 0 => info!( + Ok(outcome) if outcome.deleted_records > 0 => info!( name: "admin.invocation_log_cleanup.completed", - removed, + status = ?outcome.status, + removed = outcome.deleted_records, + requested_cutoff = %outcome.policy.requested_cutoff, + effective_cutoff = %outcome.policy.effective_cutoff, + preserved_usage_window_days = outcome.policy.preserved_usage_window_days, "expired invocation logs removed" ), - Ok(_) => {} + Ok(outcome) => info!( + name: "admin.invocation_log_cleanup.noop", + status = ?outcome.status, + requested_cutoff = %outcome.policy.requested_cutoff, + effective_cutoff = %outcome.policy.effective_cutoff, + preserved_usage_window_days = outcome.policy.preserved_usage_window_days, + "no expired invocation logs removed" + ), Err(_) => warn!( name: "admin.invocation_log_cleanup.failed", error_category = "registry_cleanup", diff --git a/apps/admin-api/src/rate_limit.rs b/apps/admin-api/src/rate_limit.rs index a23630a..f9358a2 100644 --- a/apps/admin-api/src/rate_limit.rs +++ b/apps/admin-api/src/rate_limit.rs @@ -10,20 +10,23 @@ use crank_runtime::{RateLimitCheckError, RateLimitRejection}; use crate::{error::ApiError, state::AppState}; +#[derive(Clone, Debug)] +pub struct ClientIdentityBucket(pub String); + pub async fn apply_api_rate_limit( State(state): State, - request: Request, + mut request: Request, next: Next, ) -> Result { let peer_ip = request .extensions() .get::>() .map(|ConnectInfo(address)| address.ip()); - let key = rate_limit_key( + let key = client_rate_limit_key( request.headers(), request.uri().path(), peer_ip, - state.trust_forwarded_headers, + &state.trusted_proxy_ips, ); if let Err(error) = state.api_rate_limiter.check(&key).await { return match error { @@ -37,6 +40,8 @@ pub async fn apply_api_rate_limit( }; } + request.extensions_mut().insert(ClientIdentityBucket(key)); + Ok(next.run(request).await) } @@ -46,13 +51,15 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value { }) } -fn rate_limit_key( +pub fn client_rate_limit_key( headers: &HeaderMap, path: &str, peer_ip: Option, - trust_forwarded_headers: bool, + trusted_proxy_ips: &[IpAddr], ) -> String { - if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) { + if peer_ip.is_some_and(|peer_ip| trusted_proxy_ips.contains(&peer_ip)) + && let Some(client_ip) = forwarded_client_ip(headers) + { return format!("ip:{client_ip}"); } @@ -63,7 +70,8 @@ fn rate_limit_key( format!("anonymous:{path}") } -/// Resolves the client IP from proxy headers, assuming a single trusted proxy. +/// Resolves the client IP from proxy headers after the immediate peer was +/// matched against the trusted-proxy allowlist. /// /// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the /// real peer address. For `X-Forwarded-For` the proxy *appends* the observed @@ -96,7 +104,7 @@ mod tests { use axum::http::{HeaderMap, HeaderValue, header::COOKIE}; - use super::rate_limit_key; + use super::client_rate_limit_key; fn peer() -> Option { Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))) @@ -112,7 +120,7 @@ mod tests { headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5")); assert_eq!( - rate_limit_key(&headers, "/api/auth/login", peer(), true), + client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]), "ip:10.0.0.5" ); } @@ -124,7 +132,23 @@ mod tests { headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9")); assert_eq!( - rate_limit_key(&headers, "/api/auth/login", peer(), false), + client_rate_limit_key(&headers, "/api/auth/login", peer(), &[]), + "ip:203.0.113.7" + ); + } + + #[test] + fn ignores_forwarded_headers_from_unlisted_peer() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9")); + + assert_eq!( + client_rate_limit_key( + &headers, + "/api/auth/login", + peer(), + &[IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10))] + ), "ip:203.0.113.7" ); } @@ -139,7 +163,7 @@ mod tests { headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9")); assert_eq!( - rate_limit_key(&headers, "/api/auth/login", peer(), true), + client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]), "ip:10.0.0.9" ); } @@ -155,7 +179,7 @@ mod tests { ); assert_eq!( - rate_limit_key(&headers, "/api/auth/login", peer(), true), + client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]), "ip:10.0.0.6" ); } @@ -165,7 +189,7 @@ mod tests { let headers = HeaderMap::new(); assert_eq!( - rate_limit_key(&headers, "/api/auth/login", peer(), true), + client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]), "ip:203.0.113.7" ); } @@ -180,7 +204,7 @@ mod tests { ); assert_eq!( - rate_limit_key(&headers, "/api/auth/login", peer(), true), + client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]), "ip:203.0.113.7" ); } @@ -190,7 +214,7 @@ mod tests { let headers = HeaderMap::new(); assert_eq!( - rate_limit_key(&headers, "/api/auth/login", None, false), + client_rate_limit_key(&headers, "/api/auth/login", None, &[]), "anonymous:/api/auth/login" ); } diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index f12cd64..47255b2 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -5,6 +5,7 @@ pub mod auth_profiles; pub mod capabilities; pub mod imports; pub mod observability; +pub mod onboarding; pub mod operations; pub mod secrets; pub mod upstreams; diff --git a/apps/admin-api/src/routes/agents.rs b/apps/admin-api/src/routes/agents.rs index 660294d..0c45e43 100644 --- a/apps/admin-api/src/routes/agents.rs +++ b/apps/admin-api/src/routes/agents.rs @@ -1,15 +1,19 @@ use axum::{ - Json, + Extension, Json, extract::{Path, State}, + http::{HeaderMap, header}, + response::IntoResponse, }; use serde::Deserialize; use serde_json::{Value, json}; use crate::{ + auth::AuthenticatedSession, error::ApiError, + request_context::RequestContext, service::{ - AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload, - ToolSearchPreviewPayload, UpdateAgentPayload, + AdminAuditContext, AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, + PublishPayload, ToolSearchPreviewPayload, UpdateAgentPayload, }, state::AppState, }; @@ -77,7 +81,7 @@ pub async fn create_agent( pub async fn get_agent( Path(path): Path, State(state): State, -) -> Result, ApiError> { +) -> Result { let agent = state .service .get_agent( @@ -85,20 +89,30 @@ pub async fn get_agent( &path.agent_id.as_str().into(), ) .await?; - Ok(Json(json!(agent))) + let etag = crate::service::AdminService::agent_state_etag(&agent); + let mut headers = HeaderMap::new(); + headers.insert( + header::ETAG, + header::HeaderValue::from_str(&etag) + .map_err(|_| ApiError::internal("invalid agent etag"))?, + ); + Ok((headers, Json(json!(agent)))) } pub async fn update_agent( Path(path): Path, State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result, ApiError> { + let expected_state = require_agent_precondition(&state, &path, &headers).await?; let updated = state .service .update_agent( &path.workspace_id.as_str().into(), &path.agent_id.as_str().into(), payload, + Some(&expected_state), ) .await?; Ok(Json(json!(updated))) @@ -107,12 +121,15 @@ pub async fn update_agent( pub async fn delete_agent( Path(path): Path, State(state): State, + headers: HeaderMap, ) -> Result, ApiError> { + let expected_state = require_agent_precondition(&state, &path, &headers).await?; let deleted = state .service .delete_agent( &path.workspace_id.as_str().into(), &path.agent_id.as_str().into(), + Some(&expected_state), ) .await?; Ok(Json(json!(deleted))) @@ -136,30 +153,79 @@ pub async fn get_agent_version( pub async fn save_agent_bindings( Path(path): Path, State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result, ApiError> { + let expected_state = require_agent_precondition(&state, &path, &headers).await?; let record = state .service .save_agent_bindings( &path.workspace_id.as_str().into(), &path.agent_id.as_str().into(), payload, + Some(&expected_state), ) .await?; Ok(Json(json!(record))) } +async fn require_agent_precondition( + state: &AppState, + path: &WorkspaceAgentPath, + headers: &HeaderMap, +) -> Result { + let agent = state + .service + .get_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + let expected = crate::service::AdminService::agent_state_etag(&agent); + let provided = headers + .get(header::IF_MATCH) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + ApiError::precondition_required_with_context( + "If-Match is required for Agent mutation", + json!({ + "error_code": "agent_precondition_required", + "current_version": agent.current_draft_version, + "latest_published_version": agent.latest_published_version, + "catalog_revision": agent.catalog_revision, + "recovery": "reload" + }), + ) + })?; + if provided != expected { + return Err(ApiError::conflict_with_context( + "Agent state changed; reload before retrying", + json!({ + "error_code": "agent_stale_revision", + "current_version": agent.current_draft_version, + "latest_published_version": agent.latest_published_version, + "catalog_revision": agent.catalog_revision, + "recovery": "reload" + }), + )); + } + crate::service::AdminService::agent_state_expectation(&agent) +} + pub async fn publish_agent( Path(path): Path, State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result, ApiError> { + let expected_state = require_agent_precondition(&state, &path, &headers).await?; let published = state .service .publish_agent( &path.workspace_id.as_str().into(), &path.agent_id.as_str().into(), payload.version, + Some(&expected_state), ) .await?; Ok(Json(json!(published))) @@ -168,12 +234,15 @@ pub async fn publish_agent( pub async fn unpublish_agent( Path(path): Path, State(state): State, + headers: HeaderMap, ) -> Result, ApiError> { + let expected_state = require_agent_precondition(&state, &path, &headers).await?; let updated = state .service .unpublish_agent( &path.workspace_id.as_str().into(), &path.agent_id.as_str().into(), + Some(&expected_state), ) .await?; Ok(Json(json!(updated))) @@ -182,12 +251,15 @@ pub async fn unpublish_agent( pub async fn archive_agent( Path(path): Path, State(state): State, + headers: HeaderMap, ) -> Result, ApiError> { + let expected_state = require_agent_precondition(&state, &path, &headers).await?; let updated = state .service .archive_agent( &path.workspace_id.as_str().into(), &path.agent_id.as_str().into(), + Some(&expected_state), ) .await?; Ok(Json(json!(updated))) @@ -210,14 +282,19 @@ pub async fn list_agent_platform_api_keys( pub async fn create_agent_platform_api_key( Path(path): Path, State(state): State, + Extension(session): Extension, + Extension(request_context): Extension, Json(payload): Json, ) -> Result, ApiError> { + let audit_context = + AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation); let created = state .service .create_agent_platform_api_key( &path.workspace_id.as_str().into(), &path.agent_id.as_str().into(), payload, + Some(&audit_context), ) .await?; Ok(Json(json!(created))) @@ -226,13 +303,18 @@ pub async fn create_agent_platform_api_key( pub async fn revoke_agent_platform_api_key( Path(path): Path, State(state): State, + Extension(session): Extension, + Extension(request_context): Extension, ) -> Result { + let audit_context = + AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation); state .service .revoke_agent_platform_api_key( &path.workspace_id.as_str().into(), &path.agent_id.as_str().into(), &path.key_id.as_str().into(), + Some(&audit_context), ) .await?; Ok(axum::http::StatusCode::NO_CONTENT) @@ -241,13 +323,18 @@ pub async fn revoke_agent_platform_api_key( pub async fn delete_agent_platform_api_key( Path(path): Path, State(state): State, + Extension(session): Extension, + Extension(request_context): Extension, ) -> Result { + let audit_context = + AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation); state .service .delete_agent_platform_api_key( &path.workspace_id.as_str().into(), &path.agent_id.as_str().into(), &path.key_id.as_str().into(), + Some(&audit_context), ) .await?; Ok(axum::http::StatusCode::NO_CONTENT) diff --git a/apps/admin-api/src/routes/auth.rs b/apps/admin-api/src/routes/auth.rs index df96f10..815ce2c 100644 --- a/apps/admin-api/src/routes/auth.rs +++ b/apps/admin-api/src/routes/auth.rs @@ -5,18 +5,52 @@ use serde_json::json; use crate::{ auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie}, error::ApiError, + rate_limit::ClientIdentityBucket, service::{ - ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload, + ChangePasswordPayload, CompleteBootstrapPayload, LoginPayload, + UpdateCurrentWorkspacePayload, UpdateProfilePayload, }, state::AppState, }; +pub async fn bootstrap_status( + State(state): State, +) -> Result, ApiError> { + Ok(Json(serde_json::json!( + state.service.bootstrap_status().await? + ))) +} + +pub async fn complete_bootstrap( + State(state): State, + jar: CookieJar, + Json(payload): Json, +) -> Result { + let (session_data, session) = state.service.complete_bootstrap(payload).await?; + let cookie_value = format!( + "{}.{}", + session_data.session_id.as_str(), + session_data.value + ); + let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value)); + + Ok((jar, Json(serde_json::json!(session)))) +} + pub async fn login( State(state): State, jar: CookieJar, + client_bucket: Option>, Json(payload): Json, ) -> Result { - let (session_data, session) = state.service.login(payload).await?; + let client_bucket = client_bucket + .as_ref() + .map(|Extension(bucket)| bucket.0.as_str()) + .unwrap_or("anonymous:/api/auth/login"); + let (session_data, session) = state + .service + .login_with_client_bucket(payload, client_bucket) + .await?; let cookie_value = format!( "{}.{}", session_data.session_id.as_str(), @@ -54,6 +88,22 @@ pub async fn get_session( Ok(Json(serde_json::json!(session))) } +pub async fn refresh_session_csrf( + State(state): State, + jar: CookieJar, +) -> Result, ApiError> { + let (session_id, session_value) = extract_session_token(&jar) + .ok_or_else(|| ApiError::unauthorized("authentication required"))?; + state + .service + .get_session(&session_id, &session_value) + .await? + .ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?; + let csrf_token = state.service.rotate_session_csrf_token(&session_id).await?; + + Ok(Json(json!({ "csrf_token": csrf_token }))) +} + pub async fn get_profile( Extension(session): Extension, ) -> Result, ApiError> { @@ -73,6 +123,7 @@ pub async fn update_profile( .service .update_profile( &session.user.id, + &session.session_id, session.current_workspace_id.as_ref(), payload, ) diff --git a/apps/admin-api/src/routes/auth_profiles.rs b/apps/admin-api/src/routes/auth_profiles.rs index 669d228..9aceb11 100644 --- a/apps/admin-api/src/routes/auth_profiles.rs +++ b/apps/admin-api/src/routes/auth_profiles.rs @@ -1,11 +1,17 @@ use axum::{ - Json, + Extension, Json, extract::{Path, State}, }; use serde::Deserialize; use serde_json::{Value, json}; -use crate::{error::ApiError, service::AuthProfilePayload, state::AppState}; +use crate::{ + auth::AuthenticatedSession, + error::ApiError, + request_context::RequestContext, + service::{AdminAuditContext, AuthProfilePayload}, + state::AppState, +}; #[derive(Deserialize)] pub struct WorkspacePath { @@ -32,11 +38,19 @@ pub async fn list_auth_profiles( pub async fn create_auth_profile( Path(path): Path, State(state): State, + Extension(session): Extension, + Extension(request_context): Extension, Json(payload): Json, ) -> Result, ApiError> { + let audit_context = + AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation); let profile = state .service - .create_auth_profile(&path.workspace_id.as_str().into(), payload) + .create_auth_profile( + &path.workspace_id.as_str().into(), + payload, + Some(&audit_context), + ) .await?; Ok(Json(json!(profile))) } diff --git a/apps/admin-api/src/routes/observability.rs b/apps/admin-api/src/routes/observability.rs index 84c56c4..22583c0 100644 --- a/apps/admin-api/src/routes/observability.rs +++ b/apps/admin-api/src/routes/observability.rs @@ -1,13 +1,18 @@ use axum::{ Json, extract::{Path, Query, State}, + http::{ + HeaderValue, StatusCode, + header::{CONTENT_DISPOSITION, CONTENT_TYPE}, + }, + response::{IntoResponse, Response}, }; use serde_json::{Value, json}; use crate::{ error::ApiError, routes::access::WorkspacePath, - service::{ApprovalsQuery, LogsQuery, UsageRequestQuery}, + service::{ApprovalDecisionPayload, ApprovalsQuery, LogsQuery, UsageRequestQuery}, state::AppState, }; @@ -44,7 +49,49 @@ pub async fn list_logs( .service .list_logs(&path.workspace_id.as_str().into(), query) .await?; - Ok(Json(json!({ "items": items }))) + Ok(Json(json!(items))) +} + +pub async fn export_logs_csv( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result { + let csv = state + .service + .export_logs_csv(&path.workspace_id.as_str().into(), query) + .await?; + let mut response = (StatusCode::OK, csv).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/csv; charset=utf-8"), + ); + response.headers_mut().insert( + CONTENT_DISPOSITION, + HeaderValue::from_static("attachment; filename=\"crank-invocation-history.csv\""), + ); + Ok(response) +} + +pub async fn export_usage_csv( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result { + let csv = state + .service + .export_usage_csv(&path.workspace_id.as_str().into(), query) + .await?; + let mut response = (StatusCode::OK, csv).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/csv; charset=utf-8"), + ); + response.headers_mut().insert( + CONTENT_DISPOSITION, + HeaderValue::from_static("attachment; filename=\"crank-usage.csv\""), + ); + Ok(response) } pub async fn get_log( @@ -87,6 +134,38 @@ pub async fn get_approval( Ok(Json(json!(item))) } +pub async fn approve_approval( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let item = state + .service + .approve_approval( + &path.workspace_id.as_str().into(), + &path.approval_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(item))) +} + +pub async fn deny_approval( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let item = state + .service + .deny_approval( + &path.workspace_id.as_str().into(), + &path.approval_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(item))) +} + pub async fn get_usage( Path(path): Path, Query(query): Query, diff --git a/apps/admin-api/src/routes/onboarding.rs b/apps/admin-api/src/routes/onboarding.rs new file mode 100644 index 0000000..e325722 --- /dev/null +++ b/apps/admin-api/src/routes/onboarding.rs @@ -0,0 +1,59 @@ +use axum::{ + Json, + extract::{Path, State}, +}; + +use crate::{ + error::ApiError, + service::{ + OnboardingEventPayload, OnboardingEventResponse, OnboardingResponse, + ResetOnboardingSelectionPayload, ResetOnboardingSelectionResponse, + }, + state::AppState, +}; + +#[derive(serde::Deserialize)] +pub struct WorkspaceOnboardingPath { + pub workspace_id: String, +} + +pub async fn reset_onboarding_selection( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + Ok(Json( + state + .service + .reset_onboarding_selection( + &path.workspace_id.as_str().into(), + payload.expected_revision, + ) + .await?, + )) +} + +pub async fn get_onboarding( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + Ok(Json( + state + .service + .get_onboarding(&path.workspace_id.as_str().into()) + .await?, + )) +} + +pub async fn record_onboarding_event( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + Ok(Json( + state + .service + .record_onboarding_event(&path.workspace_id.as_str().into(), payload) + .await?, + )) +} diff --git a/apps/admin-api/src/routes/operations.rs b/apps/admin-api/src/routes/operations.rs index 25debbe..168840a 100644 --- a/apps/admin-api/src/routes/operations.rs +++ b/apps/admin-api/src/routes/operations.rs @@ -1,9 +1,10 @@ use axum::{ Json, - extract::{Extension, Path, Query, State}, + extract::{Extension, Path, Query, State, rejection::StringRejection}, http::{HeaderMap, StatusCode, header}, response::IntoResponse, }; +use crank_registry::OperationStateExpectation; use serde::Deserialize; use serde_json::{Value, json}; @@ -79,7 +80,7 @@ pub async fn analyze_operation_quality( pub async fn get_operation( Path(path): Path, State(state): State, -) -> Result, ApiError> { +) -> Result { let operation = state .service .get_operation( @@ -87,20 +88,30 @@ pub async fn get_operation( &path.operation_id.as_str().into(), ) .await?; - Ok(Json(json!(operation))) + let etag = crate::service::AdminService::operation_state_etag(&operation); + let mut headers = HeaderMap::new(); + headers.insert( + header::ETAG, + header::HeaderValue::from_str(&etag) + .map_err(|_| ApiError::internal("invalid operation etag"))?, + ); + Ok((headers, Json(json!(operation)))) } pub async fn update_operation( Path(path): Path, State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result, ApiError> { + let expected = require_operation_precondition(&state, &path, &headers).await?; let result = state .service .update_operation( &path.workspace_id.as_str().into(), &path.operation_id.as_str().into(), payload, + &expected, ) .await?; Ok(Json(json!(result))) @@ -109,12 +120,15 @@ pub async fn update_operation( pub async fn delete_operation( Path(path): Path, State(state): State, + headers: HeaderMap, ) -> Result, ApiError> { + let expected = require_operation_precondition(&state, &path, &headers).await?; let result = state .service .delete_operation( &path.workspace_id.as_str().into(), &path.operation_id.as_str().into(), + &expected, ) .await?; Ok(Json(json!(result))) @@ -123,7 +137,7 @@ pub async fn delete_operation( pub async fn get_operation_version( Path(path): Path, State(state): State, -) -> Result, ApiError> { +) -> Result { let version = state .service .get_operation_version( @@ -132,20 +146,30 @@ pub async fn get_operation_version( path.version, ) .await?; - Ok(Json(json!(version))) + let etag = crate::service::AdminService::operation_version_etag(&version)?; + let mut headers = HeaderMap::new(); + headers.insert( + header::ETAG, + header::HeaderValue::from_str(&etag) + .map_err(|_| ApiError::internal("invalid operation version etag"))?, + ); + Ok((headers, Json(json!(version)))) } pub async fn create_version( Path(path): Path, State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result, ApiError> { + let expected = require_operation_precondition(&state, &path, &headers).await?; let created = state .service .create_version( &path.workspace_id.as_str().into(), &path.operation_id.as_str().into(), payload, + &expected, ) .await?; Ok(Json(json!(created))) @@ -154,14 +178,17 @@ pub async fn create_version( pub async fn publish_operation( Path(path): Path, State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result, ApiError> { + let expected = require_operation_precondition(&state, &path, &headers).await?; let published = state .service .publish_operation( &path.workspace_id.as_str().into(), &path.operation_id.as_str().into(), payload.version, + &expected, ) .await?; Ok(Json(json!(published))) @@ -170,17 +197,63 @@ pub async fn publish_operation( pub async fn archive_operation( Path(path): Path, State(state): State, + headers: HeaderMap, ) -> Result, ApiError> { + let expected = require_operation_precondition(&state, &path, &headers).await?; let archived = state .service .archive_operation( &path.workspace_id.as_str().into(), &path.operation_id.as_str().into(), + &expected, ) .await?; Ok(Json(json!(archived))) } +async fn require_operation_precondition( + state: &AppState, + path: &WorkspaceOperationPath, + headers: &HeaderMap, +) -> Result { + let detail = state + .service + .get_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + ) + .await?; + let expected = crate::service::AdminService::operation_state_etag(&detail); + let provided = headers + .get(header::IF_MATCH) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + ApiError::precondition_required_with_context( + "If-Match is required for Operation mutation", + json!({ + "error_code": "operation_precondition_required", + "current_version": detail.current_draft_version, + "recovery": "reload" + }), + ) + })?; + if provided != expected { + return Err(ApiError::conflict_with_context( + "Operation state changed; reload before retrying", + json!({ + "error_code": "operation_stale_version", + "current_version": detail.current_draft_version, + "recovery": "reload" + }), + )); + } + Ok(OperationStateExpectation { + current_draft_version: detail.current_draft_version, + status: detail.status, + latest_published_version: detail.latest_published_version, + }) +} + pub async fn run_test( Path(path): Path, State(state): State, @@ -285,11 +358,33 @@ pub async fn import_operation( Path(path): Path, Query(query): Query, State(state): State, - body: String, + headers: HeaderMap, + body: Result, ) -> Result, ApiError> { + let body = body.map_err(|rejection| match rejection { + StringRejection::InvalidUtf8(_) => ApiError::unprocessable_with_context( + "operation yaml is not valid UTF-8", + json!({ "error_code": "operation_yaml_invalid" }), + ), + StringRejection::FailedToBufferBody(_) => ApiError::payload_too_large_with_context( + "operation yaml exceeds the 256 KiB limit", + json!({ "error_code": "operation_yaml_too_large" }), + ), + _ => ApiError::unprocessable_with_context( + "operation yaml body is invalid", + json!({ "error_code": "operation_yaml_invalid" }), + ), + })?; let imported = state .service - .import_operation(&path.workspace_id.as_str().into(), query, &body) + .import_operation( + &path.workspace_id.as_str().into(), + query, + &body, + headers + .get(header::IF_MATCH) + .and_then(|value| value.to_str().ok()), + ) .await?; Ok(Json(json!(imported))) } diff --git a/apps/admin-api/src/routes/secrets.rs b/apps/admin-api/src/routes/secrets.rs index 4bb1817..c2a7e24 100644 --- a/apps/admin-api/src/routes/secrets.rs +++ b/apps/admin-api/src/routes/secrets.rs @@ -8,7 +8,8 @@ use serde_json::{Value, json}; use crate::{ auth::AuthenticatedSession, error::ApiError, - service::{RotateSecretPayload, SecretPayload}, + request_context::RequestContext, + service::{AdminAuditContext, RotateSecretPayload, SecretPayload}, state::AppState, }; @@ -38,14 +39,18 @@ pub async fn create_secret( Path(path): Path, State(state): State, Extension(session): Extension, + Extension(request_context): Extension, Json(payload): Json, ) -> Result, ApiError> { + let audit_context = + AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation); let secret = state .service .create_secret( &path.workspace_id.as_str().into(), Some(&session.user.id), payload, + Some(&audit_context), ) .await?; Ok(Json(json!(secret))) @@ -69,8 +74,11 @@ pub async fn rotate_secret( Path(path): Path, State(state): State, Extension(session): Extension, + Extension(request_context): Extension, Json(payload): Json, ) -> Result, ApiError> { + let audit_context = + AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation); let secret = state .service .rotate_secret( @@ -78,6 +86,7 @@ pub async fn rotate_secret( &path.secret_id.as_str().into(), Some(&session.user.id), payload, + Some(&audit_context), ) .await?; Ok(Json(json!(secret))) @@ -86,12 +95,17 @@ pub async fn rotate_secret( pub async fn delete_secret( Path(path): Path, State(state): State, + Extension(session): Extension, + Extension(request_context): Extension, ) -> Result, ApiError> { + let audit_context = + AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation); state .service .delete_secret( &path.workspace_id.as_str().into(), &path.secret_id.as_str().into(), + Some(&audit_context), ) .await?; Ok(Json(json!({ "ok": true }))) diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index e79ed81..1912ebe 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -4,23 +4,26 @@ use std::sync::Arc; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ - AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile, EditionCapabilities, + AuditActor, AuditEvent, AuditEventId, AuditSink, AuditTarget, AuditTargetKind, AuthProfile, + CapabilityProfile, CommunityCapabilityProfile, CorrelationContext, EditionCapabilities, ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId, InvocationSource, NoopAuditSink, OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine, - ProductEdition, Protocol, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind, - ToolQualitySchemaNode, UsagePeriod, WorkspaceId, + ProductEdition, Protocol, SecretStatus, ToolQualityMappingRule, ToolQualityMappingSet, + ToolQualitySchemaKind, ToolQualitySchemaNode, UsagePeriod, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ AgentSummary, CreateInvocationLogRequest, InvocationHistoryWriteOutcome, OperationAgentRef, - OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket, + OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryError, RegistryOperation, + UsageBucket, }; use crank_runtime::{ - OutboundHttpPolicy, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto, + OutboundHttpPolicy, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto, }; use crank_schema::{Schema, SchemaKind}; use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query}; -use serde_json::{Value, json}; +use rand::RngExt; +use serde_json::json; use sha2::{Digest, Sha256}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::Instrument; @@ -33,6 +36,7 @@ mod demo; mod import_export; mod imports; mod observability; +mod onboarding; mod operation_validation; mod operations; mod samples; @@ -40,7 +44,11 @@ mod secrets; mod upstreams; mod workspaces; -use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage}; +use crate::{ + auth::{AuthSettings, AuthenticatedSession}, + error::ApiError, + storage::LocalArtifactStorage, +}; use operation_validation::{ validate_approval_policy, validate_execution_timeout, validate_idempotency_policy, validate_protocol_target, validate_response_cache_policy, @@ -58,6 +66,7 @@ pub struct AdminService { audit_sink: Arc, capability_profile: Arc, outbound_http_policy: OutboundHttpPolicy, + public_base_url: String, } pub struct AdminServiceBuilder { @@ -71,10 +80,46 @@ pub struct AdminServiceBuilder { audit_sink: Option>, capability_profile: Option>, outbound_http_policy: OutboundHttpPolicy, + public_base_url: String, } pub use crate::dto::*; +#[derive(Clone, Debug)] +pub struct AdminAuditContext { + actor: AuditActor, + request_id: String, + trace_id: String, +} + +impl AdminAuditContext { + pub fn from_session_and_correlation( + session: &AuthenticatedSession, + correlation: &CorrelationContext, + ) -> Self { + Self { + actor: AuditActor { + user_id: session.user.id.clone(), + email: session.user.email.clone(), + session_id: Some(session.session_id.clone()), + }, + request_id: correlation.request_id().as_str().to_owned(), + trace_id: correlation.trace_id().as_str().to_owned(), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct CredentialAuditRecord<'a> { + pub action: &'static str, + pub target_kind: AuditTargetKind, + pub workspace_id: &'a WorkspaceId, + pub target_id: &'a str, + pub credential_type: &'static str, + pub outcome: &'static str, + pub reason: &'a str, +} + impl AdminService { pub async fn readiness(&self) -> Result<(), ApiError> { self.registry.ping().await?; @@ -132,6 +177,19 @@ impl AdminService { } } +fn runtime_credential_error_code(error: &RuntimeError) -> &'static str { + match error { + RuntimeError::AuthorizationStoreUnavailable => "authorization_store_unavailable", + RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found", + RuntimeError::InvalidAuthProfileConfig { .. } => "auth_profile_invalid", + RuntimeError::MissingSecret { .. } => "secret_not_found", + RuntimeError::MissingSecretVersion { .. } => "secret_version_not_found", + RuntimeError::InvalidAuthSecretValue { .. } => "secret_invalid", + RuntimeError::SecretCrypto { .. } => "secret_crypto_failed", + _ => "credential_resolution_failed", + } +} + impl AdminServiceBuilder { pub fn new( registry: PostgresRegistry, @@ -151,6 +209,7 @@ impl AdminServiceBuilder { audit_sink: None, capability_profile: None, outbound_http_policy: OutboundHttpPolicy::default(), + public_base_url: "http://localhost:3000".to_owned(), } } @@ -164,6 +223,11 @@ impl AdminServiceBuilder { self } + pub fn with_public_base_url(mut self, public_base_url: String) -> Self { + self.public_base_url = public_base_url.trim_end_matches('/').to_owned(); + self + } + #[allow(dead_code)] pub fn with_policy_engine(mut self, policy_engine: Arc) -> Self { self.policy_engine = Some(policy_engine); @@ -201,6 +265,7 @@ impl AdminServiceBuilder { .capability_profile .unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)), outbound_http_policy: self.outbound_http_policy, + public_base_url: self.public_base_url, } } } @@ -254,10 +319,56 @@ impl AdminService { self.capability_profile().capabilities() } + async fn record_credential_audit( + &self, + audit_context: Option<&AdminAuditContext>, + record: CredentialAuditRecord<'_>, + ) { + let Some(audit_context) = audit_context else { + return; + }; + + let event = AuditEvent { + id: AuditEventId::new(new_prefixed_id("audit")), + occurred_at: OffsetDateTime::now_utc(), + actor: audit_context.actor.clone(), + action: record.action.to_owned(), + target: AuditTarget { + workspace_id: record.workspace_id.clone(), + kind: record.target_kind, + id: record.target_id.to_owned(), + }, + payload: json!({ + "credential_type": record.credential_type, + "outcome": record.outcome, + "reason": record.reason, + "request_id": audit_context.request_id, + "trace_id": audit_context.trace_id + }), + source_ip: None, + user_agent: None, + }; + + if self.audit_sink.record(event).await.is_err() { + tracing::warn!( + name: "admin.credential_audit.lost", + action = record.action, + credential_type = record.credential_type, + outcome = record.outcome, + reason = record.reason, + request_id = %audit_context.request_id, + trace_id = %audit_context.trace_id, + error_code = "audit_sink_failed", + "credential audit event was not recorded" + ); + } + } + async fn resolve_operation_auth( &self, workspace_id: &WorkspaceId, execution_config: &crank_core::ExecutionConfig, + audit_context: Option<&AdminAuditContext>, ) -> Result, RuntimeError> { let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else { return Ok(None); @@ -271,15 +382,14 @@ impl AdminService { .get_auth_profile(workspace_id, auth_profile_id), ) .await - .map_err(|error| RuntimeError::SecretCrypto { - operation: "load auth profile", - details: error.to_string(), - })? - .ok_or_else(|| RuntimeError::MissingAuthProfile { - auth_profile_id: auth_profile_id.as_str().to_owned(), - })?; + .map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?; + let Some(auth_profile) = auth_profile else { + return Err(RuntimeError::MissingAuthProfile { + auth_profile_id: auth_profile_id.as_str().to_owned(), + }); + }; - self.resolve_auth_profile(workspace_id, &auth_profile) + self.resolve_auth_profile(workspace_id, &auth_profile, audit_context) .await .map(Some) } @@ -292,6 +402,17 @@ impl AdminService { ErrorCategory::Configuration.record(&span); } } + if let Err(error) = &result { + self.record_credential_resolution_failure( + audit_context, + workspace_id, + crank_core::AuditTargetKind::AuthProfile, + auth_profile_id.as_str(), + "auth_profile", + runtime_credential_error_code(error), + ) + .await; + } result } @@ -299,6 +420,7 @@ impl AdminService { &self, workspace_id: &WorkspaceId, auth_profile: &AuthProfile, + audit_context: Option<&AdminAuditContext>, ) -> Result { let mut secrets = BTreeMap::new(); let used_at = OffsetDateTime::now_utc(); @@ -309,45 +431,156 @@ impl AdminService { self.registry.get_secret(workspace_id, secret_id), ) .await - .map_err(|error| RuntimeError::SecretCrypto { - operation: "load secret", - details: error.to_string(), - })? - .ok_or_else(|| RuntimeError::MissingSecret { - secret_id: secret_id.as_str().to_owned(), - })?; + .map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?; + let Some(secret) = secret else { + let error = RuntimeError::MissingSecret { + secret_id: secret_id.as_str().to_owned(), + }; + self.record_credential_resolution_failure( + audit_context, + workspace_id, + crank_core::AuditTargetKind::Secret, + secret_id.as_str(), + "secret", + runtime_credential_error_code(&error), + ) + .await; + return Err(error); + }; + if secret.secret.status != SecretStatus::Active { + let error = RuntimeError::InvalidAuthSecretValue { + secret_id: secret_id.as_str().to_owned(), + reason: "secret is not active".to_owned(), + }; + self.record_credential_resolution_failure( + audit_context, + workspace_id, + crank_core::AuditTargetKind::Secret, + secret_id.as_str(), + "secret", + runtime_credential_error_code(&error), + ) + .await; + return Err(error); + } let version = observe_db_query( DbOperation::SecretRead, self.registry .get_current_secret_version(workspace_id, secret_id), ) .await - .map_err(|error| RuntimeError::SecretCrypto { - operation: "load current secret version", - details: error.to_string(), - })? - .ok_or_else(|| RuntimeError::MissingSecretVersion { - secret_id: secret_id.as_str().to_owned(), - version: secret.secret.current_version, - })?; - let plaintext = self.secret_crypto.decrypt( + .map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?; + let Some(version) = version else { + let error = RuntimeError::MissingSecretVersion { + secret_id: secret_id.as_str().to_owned(), + version: secret.secret.current_version, + }; + self.record_credential_resolution_failure( + audit_context, + workspace_id, + crank_core::AuditTargetKind::Secret, + secret_id.as_str(), + "secret", + runtime_credential_error_code(&error), + ) + .await; + return Err(error); + }; + let plaintext = self.secret_crypto.decrypt_for_epoch( &version.secret_version.key_version, + version.master_key_epoch, &version.secret_version.ciphertext, - )?; + ); + let plaintext = match plaintext { + Ok(plaintext) => plaintext, + Err(error) => { + self.record_credential_resolution_failure( + audit_context, + workspace_id, + crank_core::AuditTargetKind::Secret, + secret_id.as_str(), + "secret", + runtime_credential_error_code(&error), + ) + .await; + return Err(error); + } + }; observe_db_query( DbOperation::SecretTouch, self.registry .touch_secret(workspace_id, secret_id, &used_at), ) .await - .map_err(|error| RuntimeError::SecretCrypto { - operation: "touch secret", - details: error.to_string(), - })?; + .unwrap_or_else(|_| { + tracing::warn!( + name: "admin.secret.touch_failed", + secret_id = %secret_id.as_str(), + request_id = audit_context + .map(|context| context.request_id.as_str()) + .unwrap_or("unavailable"), + trace_id = audit_context + .map(|context| context.trace_id.as_str()) + .unwrap_or("unavailable"), + error_code = "secret_touch_failed", + "secret last-used metadata was not updated" + ); + }); secrets.insert(secret_id.clone(), plaintext); } - ResolvedAuth::from_profile(auth_profile, &secrets) + let result = ResolvedAuth::from_profile(auth_profile, &secrets); + if let Err(error) = &result { + self.record_credential_resolution_failure( + audit_context, + workspace_id, + crank_core::AuditTargetKind::AuthProfile, + auth_profile.id.as_str(), + "auth_profile", + runtime_credential_error_code(error), + ) + .await; + } + result + } + + async fn record_credential_resolution_failure( + &self, + audit_context: Option<&AdminAuditContext>, + workspace_id: &WorkspaceId, + target_kind: crank_core::AuditTargetKind, + target_id: &str, + credential_type: &'static str, + reason: &'static str, + ) { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.resolve_denied", + target_kind, + workspace_id, + target_id, + credential_type, + outcome: "failure", + reason, + }, + ) + .await; + tracing::warn!( + name: "admin.credential.resolve_denied", + credential_type, + target_kind = ?target_kind, + target_id, + outcome = "failure", + reason, + request_id = audit_context + .map(|context| context.request_id.as_str()) + .unwrap_or("unavailable"), + trace_id = audit_context + .map(|context| context.trace_id.as_str()) + .unwrap_or("unavailable"), + "credential resolution was denied" + ); } fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> { @@ -376,6 +609,70 @@ impl AdminService { Ok(()) } + async fn validate_registry_operation_in_workspace( + &self, + workspace_id: &WorkspaceId, + operation: &RegistryOperation, + ) -> Result<(), ApiError> { + self.validate_registry_operation(operation)?; + if let Some(auth_profile_id) = operation.execution_config.auth_profile_ref.as_ref() { + let Some(auth_profile) = self + .registry + .get_auth_profile(workspace_id, auth_profile_id) + .await? + else { + return Err(ApiError::unprocessable_with_context( + "operation auth profile reference is unavailable", + json!({ "error_code": "operation_auth_profile_invalid" }), + )); + }; + self.validate_auth_profile_secret_refs_for_execution(workspace_id, &auth_profile) + .await?; + } + Ok(()) + } + + async fn validate_auth_profile_secret_refs_for_execution( + &self, + workspace_id: &WorkspaceId, + auth_profile: &AuthProfile, + ) -> Result<(), ApiError> { + if auth_profile.kind != auth_profile.config.kind() { + return Err(ApiError::unprocessable_with_context( + "operation auth profile kind and config do not match", + json!({ "error_code": "operation_auth_profile_invalid" }), + )); + } + + for secret_id in auth_profile.config.secret_ids() { + let Some(secret) = self.registry.get_secret(workspace_id, secret_id).await? else { + return Err(ApiError::unprocessable_with_context( + "operation auth profile secret reference is unavailable", + json!({ "error_code": "operation_auth_profile_invalid" }), + )); + }; + if secret.secret.status != SecretStatus::Active { + return Err(RegistryError::SecretInactive { + secret_id: secret_id.as_str().to_owned(), + } + .into()); + } + if self + .registry + .get_current_secret_version(workspace_id, secret_id) + .await? + .is_none() + { + return Err(ApiError::unprocessable_with_context( + "operation auth profile secret has no current version", + json!({ "error_code": "operation_auth_profile_invalid" }), + )); + } + } + + Ok(()) + } + fn validate_outbound_target(&self, target: &crank_core::Target) -> Result<(), ApiError> { match target { crank_core::Target::Rest(rest) => self @@ -462,7 +759,9 @@ impl AdminService { id: InvocationLogId::new(new_prefixed_id("log")), workspace_id: request.workspace_id.clone(), agent_id: request.agent_id.cloned(), + platform_api_key_id: None, operation_id: request.operation.id.clone(), + operation_version: Some(request.operation.version), source: request.source, level: request.level, status: request.status, @@ -473,6 +772,10 @@ impl AdminService { status_code: request.status_code, duration_ms: request.duration_ms, error_kind: request.error_kind, + execution_stage: request.execution_stage, + execution_error_code: request.execution_error_code, + retryability: request.retryability, + outcome_certainty: request.outcome_certainty, request_preview: request.request_preview, response_preview: request.response_preview, created_at: OffsetDateTime::now_utc(), @@ -553,29 +856,6 @@ fn invocation_status_label(status: crank_core::InvocationStatus) -> &'static str } } -fn build_request_preview( - mapping: &MappingSet, - input: &Value, -) -> Result { - let prepared = if mapping.is_empty() { - PreparedRequest::default() - } else { - let mapped = mapping.apply(&json!({ "mcp": input }))?; - PreparedRequest::from_mapping_output(&mapped).map_err(|error| { - crank_mapping::MappingError::InvalidJsonPath { - path: error.to_string(), - } - })? - }; - - Ok(json!({ - "path": prepared.path_params, - "query": prepared.query_params, - "headers": prepared.headers, - "body": prepared.body.unwrap_or(Value::Null) - })) -} - fn validate_profile_display_name(value: &str) -> Result { let display_name = value.trim(); if display_name.is_empty() { @@ -616,7 +896,9 @@ fn new_prefixed_id(prefix: &str) -> String { } fn generate_access_secret(marker: &str) -> String { - let random = URL_SAFE_NO_PAD.encode(Uuid::now_v7().as_bytes()); + let mut secret_bytes = [0_u8; 32]; + rand::rng().fill(&mut secret_bytes); + let random = URL_SAFE_NO_PAD.encode(secret_bytes); format!("{marker}{random}") } @@ -640,7 +922,7 @@ fn format_timestamp(timestamp: OffsetDateTime) -> String { fn map_identity_error(error: IdentityError) -> ApiError { match error { IdentityError::BadCredentials => ApiError::unauthorized("invalid email or password"), - IdentityError::AccountDisabled => ApiError::forbidden("account is disabled"), + IdentityError::AccountDisabled => ApiError::unauthorized("invalid email or password"), IdentityError::NotSupportedForProvider => ApiError::internal( "password login is not supported by the configured identity provider", ), @@ -648,32 +930,6 @@ fn map_identity_error(error: IdentityError) -> ApiError { } } -fn runtime_error_code(error: &RuntimeError) -> &'static str { - match error { - RuntimeError::Schema(_) => "schema_error", - RuntimeError::Mapping(_) => "mapping_error", - RuntimeError::InvalidPreparedRequest { .. } => "invalid_request", - RuntimeError::ConfirmationRequired { .. } => "confirmation_required", - RuntimeError::InvalidConfirmationToken { .. } => "invalid_confirmation_token", - RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation_unavailable", - RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency_unavailable", - RuntimeError::IdempotencyInProgress { .. } => "idempotency_in_progress", - RuntimeError::IdempotencyConflict { .. } => "idempotency_conflict", - RuntimeError::IdempotencyOutcomeUnknown { .. } => "idempotency_outcome_unknown", - RuntimeError::RestAdapter(_) => "rest_error", - RuntimeError::ProtocolAdapter(_) => "adapter_error", - RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", - RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded", - RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", - RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found", - RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { - "secret_not_found" - } - RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error", - RuntimeError::SecretCrypto { .. } => "secret_crypto_error", - } -} - fn protocol_capability_view( protocol: Protocol, _edition: ProductEdition, @@ -698,7 +954,9 @@ fn protocol_capability_view( } } -fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> { +fn usage_window( + period: UsagePeriod, +) -> Result<(UsagePeriod, String, String, UsageBucket), ApiError> { let now = OffsetDateTime::now_utc(); let (start, bucket) = match period { UsagePeriod::Last30Minutes => ( @@ -746,13 +1004,14 @@ fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket ), }; - Ok(( - period, - start - .format(&Rfc3339) - .map_err(|error| ApiError::internal(error.to_string()))?, - bucket, - )) + let created_after = start + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string()))?; + let created_before = now + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string()))?; + + Ok((period, created_after, created_before, bucket)) } fn today_start_utc() -> Result { @@ -810,6 +1069,7 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView { status: summary.status, current_draft_version: summary.current_draft_version, latest_published_version: summary.latest_published_version, + catalog_revision: summary.catalog_revision, created_at: format_timestamp(summary.created_at), updated_at: format_timestamp(summary.updated_at), published_at: summary.published_at.map(format_timestamp), @@ -822,10 +1082,24 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView { } } -fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String { +pub(crate) fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String { format!("/mcp/v1/{workspace_slug}/{agent_slug}") } +impl AdminService { + pub(crate) fn public_agent_mcp_endpoint( + &self, + workspace_slug: &str, + agent_slug: &str, + ) -> String { + format!( + "{}{}", + self.public_base_url, + agent_mcp_endpoint(workspace_slug, agent_slug) + ) + } +} + fn tool_quality_schema_node(schema: &Schema) -> ToolQualitySchemaNode { ToolQualitySchemaNode { kind: tool_quality_schema_kind(&schema.kind), @@ -1013,6 +1287,7 @@ fn enrich_operation_summary( status: summary.status, current_draft_version: summary.current_draft_version, latest_published_version: summary.latest_published_version, + can_delete: summary.can_delete, created_at: format_timestamp(summary.created_at), updated_at: format_timestamp(summary.updated_at), published_at: summary.published_at.map(format_timestamp), diff --git a/apps/admin-api/src/service/agents.rs b/apps/admin-api/src/service/agents.rs index bf72666..f9daa01 100644 --- a/apps/admin-api/src/service/agents.rs +++ b/apps/admin-api/src/service/agents.rs @@ -5,11 +5,13 @@ use crank_core::{ ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog, }; use crank_registry::{ - AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest, - SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery, + AgentStateExpectation, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, + PublishAgentRequest, SaveAgentCatalogConfigRequest, UpdateAgentSummaryRequest, UsageBucket, + UsageQuery, }; use serde_json::json; -use time::OffsetDateTime; +use sha2::{Digest, Sha256}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::{info, instrument}; use crate::{ @@ -17,12 +19,50 @@ use crate::{ service::{ AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView, CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload, - agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id, + agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id, now_string, today_start_utc, }, }; impl AdminService { + pub fn agent_state_etag(agent: &AgentSummaryView) -> String { + let policy = + serde_json::to_string(&agent.tool_selection_policy).unwrap_or_else(|_| "{}".to_owned()); + let operation_ids = agent.operation_ids.join(","); + let material = format!( + "agent-state-v2\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{:?}\0{:?}\0{}\0{}\0{}\0{}\0{}", + agent.workspace_id, + agent.id, + agent.slug, + agent.display_name, + agent.description, + agent.updated_at, + agent.current_draft_version, + agent.status, + agent.latest_published_version, + agent.catalog_revision, + agent.operation_count, + operation_ids, + policy, + agent.key_count + ); + format!("\"{:x}\"", Sha256::digest(material.as_bytes())) + } + + pub fn agent_state_expectation( + agent: &AgentSummaryView, + ) -> Result { + let updated_at = OffsetDateTime::parse(&agent.updated_at, &Rfc3339) + .map_err(|_| ApiError::internal("invalid agent state timestamp"))?; + Ok(AgentStateExpectation { + status: agent.status, + current_draft_version: agent.current_draft_version, + latest_published_version: agent.latest_published_version, + catalog_revision: agent.catalog_revision, + updated_at, + }) + } + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))] pub async fn preview_tool_search( &self, @@ -110,13 +150,16 @@ impl AdminService { self.ensure_workspace_exists(workspace_id).await?; let workspace = self.get_workspace(workspace_id).await?; let summaries = self.registry.list_agents(workspace_id).await?; + let usage_start = today_start_utc()?; + let usage_end = now_string()?; let usage = self .registry .list_usage_by_agent(UsageQuery { workspace_id, period: UsagePeriod::Last24Hours, source: None, - created_after: &today_start_utc()?, + created_after: &usage_start, + created_before: &usage_end, bucket: UsageBucket::Hour, }) .await?; @@ -189,6 +232,8 @@ impl AdminService { .iter() .map(|binding| binding.operation_id.as_str().to_owned()) .collect::>(); + let usage_start = today_start_utc()?; + let usage_end = now_string()?; let usage = self .registry .get_usage_for_agent( @@ -196,7 +241,8 @@ impl AdminService { workspace_id, period: UsagePeriod::Last24Hours, source: None, - created_after: &today_start_utc()?, + created_after: &usage_start, + created_before: &usage_end, bucket: UsageBucket::Hour, }, agent_id, @@ -317,6 +363,7 @@ impl AdminService { workspace_id: &WorkspaceId, agent_id: &AgentId, payload: UpdateAgentPayload, + expected_state: Option<&AgentStateExpectation>, ) -> Result { let existing = self .registry @@ -328,6 +375,20 @@ impl AdminService { json!({ "agent_id": agent_id.as_str() }), ) })?; + if existing.status == AgentStatus::Published + && (payload.slug != existing.slug + || payload.display_name != existing.display_name + || payload.description != existing.description) + { + return Err(ApiError::conflict_with_context( + "published Agent summary is immutable; unpublish before editing", + json!({ + "agent_id": agent_id.as_str(), + "error_code": "agent_published_summary_immutable", + "recovery": "unpublish_edit_publish" + }), + )); + } if payload.slug != existing.slug && self @@ -346,10 +407,13 @@ impl AdminService { .update_agent_summary( workspace_id, agent_id, - &payload.slug, - &payload.display_name, - &payload.description, - &updated_at, + UpdateAgentSummaryRequest { + slug: &payload.slug, + display_name: &payload.display_name, + description: &payload.description, + updated_at: &updated_at, + expected_state, + }, ) .await?; @@ -365,6 +429,7 @@ impl AdminService { &self, workspace_id: &WorkspaceId, agent_id: &AgentId, + expected_state: Option<&AgentStateExpectation>, ) -> Result { let existing = self .registry @@ -376,8 +441,20 @@ impl AdminService { json!({ "agent_id": agent_id.as_str() }), ) })?; + if existing.latest_published_version.is_some() { + return Err(ApiError::conflict_with_context( + "published Agent cannot be deleted; archive or unpublish it instead", + json!({ + "agent_id": agent_id.as_str(), + "error_code": "agent_delete_forbidden", + "recovery": "archive_or_unpublish" + }), + )); + } - self.registry.delete_agent(workspace_id, agent_id).await?; + self.registry + .delete_agent(workspace_id, agent_id, expected_state) + .await?; Ok(AgentMutationResult { agent_id: agent_id.as_str().to_owned(), @@ -392,6 +469,7 @@ impl AdminService { workspace_id: &WorkspaceId, agent_id: &AgentId, payload: AgentCatalogPayload, + expected_state: Option<&AgentStateExpectation>, ) -> Result { let current_version = self .ensure_editable_agent_version(workspace_id, agent_id) @@ -408,6 +486,8 @@ impl AdminService { enabled: binding.enabled, }) .collect::>(); + self.validate_exact_published_agent_bindings(workspace_id, &bindings) + .await?; let tool_selection_policy = requested_policy .unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone()); validate_tool_selection_policy(&tool_selection_policy, &bindings)?; @@ -419,6 +499,7 @@ impl AdminService { agent_version: current_version.version, bindings: &bindings, tool_selection_policy: &tool_selection_policy, + expected_state, }) .await?; info!( @@ -474,19 +555,23 @@ impl AdminService { workspace_id: &WorkspaceId, agent_id: &AgentId, version: u32, + expected_state: Option<&AgentStateExpectation>, ) -> Result { let agent_version = self .get_agent_version(workspace_id, agent_id, version) .await?; - let published_bindings = self - .published_agent_bindings(workspace_id, &agent_version.bindings) - .await?; validate_tool_selection_policy( &agent_version.snapshot.tool_selection_policy, - &published_bindings, + &agent_version.bindings, )?; - if published_bindings.is_empty() { + if agent_version + .bindings + .iter() + .filter(|binding| binding.enabled) + .count() + == 0 + { return Err(ApiError::conflict_with_context( "agent cannot be published without published enabled tools", json!({ @@ -497,36 +582,10 @@ impl AdminService { )); } + self.validate_exact_published_agent_bindings(workspace_id, &agent_version.bindings) + .await?; + let published_at = OffsetDateTime::now_utc(); - if published_bindings != agent_version.bindings { - let draft_version = AgentVersion { - agent_id: agent_id.clone(), - version: agent_version.version + 1, - status: AgentStatus::Draft, - instructions: agent_version.snapshot.instructions.clone(), - tool_selection_policy: agent_version.snapshot.tool_selection_policy.clone(), - created_at: published_at, - }; - - self.registry - .create_agent_draft_version(CreateAgentDraftVersionRequest { - workspace_id, - agent_id, - version: &draft_version, - bindings: &agent_version.bindings, - updated_at: &published_at, - }) - .await?; - - self.registry - .save_agent_bindings(SaveAgentBindingsRequest { - workspace_id, - agent_id, - agent_version: agent_version.version, - bindings: &published_bindings, - }) - .await?; - } self.registry .publish_agent(PublishAgentRequest { @@ -535,6 +594,7 @@ impl AdminService { version, published_at: &published_at, published_by: None, + expected_state, }) .await?; info!( @@ -552,41 +612,65 @@ impl AdminService { }) } - async fn published_agent_bindings( + async fn validate_exact_published_agent_bindings( &self, workspace_id: &WorkspaceId, bindings: &[AgentOperationBinding], - ) -> Result, ApiError> { - let mut published = Vec::new(); - + ) -> Result<(), ApiError> { for binding in bindings { - if !binding.enabled { - continue; - } - let Some(summary) = self .registry .get_operation_summary(workspace_id, &binding.operation_id) .await? else { - continue; + return Err(ApiError::not_found_with_context( + "operation was not found for Agent binding", + json!({ + "error_code": "agent_binding_scope_denied", + "operation_id": binding.operation_id.as_str() + }), + )); }; + if summary.status == crank_core::OperationStatus::Archived { + return Err(ApiError::unprocessable_with_context( + "archived operation cannot be added to an Agent binding", + json!({ + "error_code": "agent_binding_archived_operation", + "operation_id": binding.operation_id.as_str() + }), + )); + } - let Some(operation_version) = summary.latest_published_version else { - continue; + let Some(version) = self + .registry + .get_operation_version( + workspace_id, + &binding.operation_id, + binding.operation_version, + ) + .await? + else { + return Err(ApiError::unprocessable_with_context( + "operation version is not published for Agent binding", + json!({ + "error_code": "agent_binding_not_published", + "operation_id": binding.operation_id.as_str(), + "operation_version": binding.operation_version + }), + )); }; - - published.push(AgentOperationBinding { - operation_id: summary.id, - operation_version, - tool_name: binding.tool_name.clone(), - tool_title: binding.tool_title.clone(), - tool_description_override: binding.tool_description_override.clone(), - enabled: true, - }); + if !version.snapshot.is_published() { + return Err(ApiError::unprocessable_with_context( + "operation version is not published for Agent binding", + json!({ + "error_code": "agent_binding_not_published", + "operation_id": binding.operation_id.as_str(), + "operation_version": binding.operation_version + }), + )); + } } - - Ok(published) + Ok(()) } #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] @@ -594,11 +678,12 @@ impl AdminService { &self, workspace_id: &WorkspaceId, agent_id: &AgentId, + expected_state: Option<&AgentStateExpectation>, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; let updated_at = OffsetDateTime::now_utc(); self.registry - .unpublish_agent(workspace_id, agent_id, &updated_at) + .unpublish_agent(workspace_id, agent_id, &updated_at, expected_state) .await?; info!( name: "admin.agent.unpublished", @@ -618,11 +703,12 @@ impl AdminService { &self, workspace_id: &WorkspaceId, agent_id: &AgentId, + expected_state: Option<&AgentStateExpectation>, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; let updated_at = OffsetDateTime::now_utc(); self.registry - .archive_agent(workspace_id, agent_id, &updated_at) + .archive_agent(workspace_id, agent_id, &updated_at, expected_state) .await?; info!( name: "admin.agent.archived", diff --git a/apps/admin-api/src/service/api_keys.rs b/apps/admin-api/src/service/api_keys.rs index 744e73a..466f552 100644 --- a/apps/admin-api/src/service/api_keys.rs +++ b/apps/admin-api/src/service/api_keys.rs @@ -6,12 +6,14 @@ use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord}; use serde_json::json; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::instrument; +use url::Url; use crate::{ error::ApiError, service::{ - AdminService, CreatedPlatformApiKeyResponse, PlatformApiKeyPayload, generate_access_secret, - hash_access_secret, new_prefixed_id, + AdminAuditContext, AdminService, CreatedPlatformApiKeyResponse, CredentialAuditRecord, + EphemeralMcpClientConfig, EphemeralMcpConnection, PlatformApiKeyPayload, + generate_access_secret, hash_access_secret, new_prefixed_id, }, }; @@ -38,32 +40,84 @@ impl AdminService { .await?) } - #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))] + #[instrument(skip(self, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))] pub async fn create_agent_platform_api_key( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, - payload: PlatformApiKeyPayload, + mut payload: PlatformApiKeyPayload, + audit_context: Option<&AdminAuditContext>, ) -> Result { - self.ensure_workspace_exists(workspace_id).await?; - self.registry + let workspace = match self.get_workspace(workspace_id).await { + Ok(workspace) => workspace, + Err(error) => { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.platform_api_key.create_failed", + target_kind: crank_core::AuditTargetKind::PlatformApiKey, + workspace_id, + target_id: "pending", + credential_type: "platform_api_key", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + }; + let agent_result = match self + .registry .get_agent_summary(workspace_id, agent_id) - .await? - .ok_or_else(|| { + .await + { + Ok(agent) => agent.ok_or_else(|| { ApiError::not_found_with_context( format!("agent {} was not found", agent_id.as_str()), json!({ "agent_id": agent_id.as_str() }), ) - })?; + }), + Err(error) => Err(ApiError::from(error)), + }; + let agent = match agent_result { + Ok(agent) => agent, + Err(error) => { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.platform_api_key.create_failed", + target_kind: crank_core::AuditTargetKind::PlatformApiKey, + workspace_id, + target_id: "pending", + credential_type: "platform_api_key", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + }; - validate_platform_api_key_payload(&payload)?; - - let expires_at = match payload.expires_at.as_deref() { - Some(value) => Some( - OffsetDateTime::parse(value, &Rfc3339) - .map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))?, - ), - None => None, + let expires_at = match validate_platform_api_key_payload(&mut payload) { + Ok(expires_at) => expires_at, + Err(error) => { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.platform_api_key.create_failed", + target_kind: crank_core::AuditTargetKind::PlatformApiKey, + workspace_id, + target_id: "pending", + credential_type: payload.key_kind.audit_credential_type(), + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } }; let secret = generate_access_secret(payload.key_kind.secret_marker()); let api_key = PlatformApiKeyRecord { @@ -83,55 +137,213 @@ impl AdminService { }, }; - self.registry + if let Err(error) = self + .registry .create_platform_api_key(CreatePlatformApiKeyRequest { api_key: &api_key.api_key, secret_hash: &hash_access_secret(&secret), }) - .await?; + .await + { + let error = ApiError::from(error); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.platform_api_key.create_failed", + target_kind: crank_core::AuditTargetKind::PlatformApiKey, + workspace_id, + target_id: api_key.api_key.id.as_str(), + credential_type: api_key.api_key.key_kind.audit_credential_type(), + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.platform_api_key.created", + target_kind: crank_core::AuditTargetKind::PlatformApiKey, + workspace_id, + target_id: api_key.api_key.id.as_str(), + credential_type: api_key.api_key.key_kind.audit_credential_type(), + outcome: "success", + reason: "credential_created", + }, + ) + .await; - Ok(CreatedPlatformApiKeyResponse { api_key, secret }) + let connection = (api_key.api_key.key_kind == PlatformApiKeyKind::McpClient).then(|| { + let endpoint = self.public_agent_mcp_endpoint(&workspace.workspace.slug, &agent.slug); + EphemeralMcpConnection { + endpoint: endpoint.clone(), + clients: ephemeral_client_configs(&endpoint, &secret), + secret_display: "once", + } + }); + Ok(CreatedPlatformApiKeyResponse { + api_key, + secret, + connection, + }) } - #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))] + #[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))] pub async fn revoke_agent_platform_api_key( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, key_id: &PlatformApiKeyId, + audit_context: Option<&AdminAuditContext>, ) -> Result<(), ApiError> { - self.registry + if let Err(error) = self + .registry .revoke_platform_api_key_for_agent( workspace_id, agent_id, key_id, &OffsetDateTime::now_utc(), ) - .await?; + .await + { + let error = ApiError::from(error); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.platform_api_key.revoke_failed", + target_kind: crank_core::AuditTargetKind::PlatformApiKey, + workspace_id, + target_id: key_id.as_str(), + credential_type: "platform_api_key", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.platform_api_key.revoked", + target_kind: crank_core::AuditTargetKind::PlatformApiKey, + workspace_id, + target_id: key_id.as_str(), + credential_type: "platform_api_key", + outcome: "success", + reason: "credential_revoked", + }, + ) + .await; Ok(()) } - #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))] + #[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))] pub async fn delete_agent_platform_api_key( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, key_id: &PlatformApiKeyId, + audit_context: Option<&AdminAuditContext>, ) -> Result<(), ApiError> { - self.registry + if let Err(error) = self + .registry .delete_platform_api_key_for_agent(workspace_id, agent_id, key_id) - .await?; + .await + { + let error = ApiError::from(error); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.platform_api_key.delete_failed", + target_kind: crank_core::AuditTargetKind::PlatformApiKey, + workspace_id, + target_id: key_id.as_str(), + credential_type: "platform_api_key", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.platform_api_key.deleted", + target_kind: crank_core::AuditTargetKind::PlatformApiKey, + workspace_id, + target_id: key_id.as_str(), + credential_type: "platform_api_key", + outcome: "success", + reason: "credential_deleted", + }, + ) + .await; Ok(()) } } -fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> { +fn ephemeral_client_configs(endpoint: &str, secret: &str) -> Vec { + ["claude_desktop", "cursor", "vscode"] + .into_iter() + .map(|client| EphemeralMcpClientConfig { + client: client.to_owned(), + config: json!({ + "transport": "streamable_http", + "url": endpoint, + "headers": {"Authorization": format!("Bearer {secret}")} + }), + }) + .collect() +} + +trait PlatformApiKeyKindAuditExt { + fn audit_credential_type(self) -> &'static str; +} + +impl PlatformApiKeyKindAuditExt for PlatformApiKeyKind { + fn audit_credential_type(self) -> &'static str { + match self { + PlatformApiKeyKind::McpClient => "mcp_client_key", + PlatformApiKeyKind::Approval => "approval_key", + } + } +} + +fn validate_platform_api_key_payload( + payload: &mut PlatformApiKeyPayload, +) -> Result, ApiError> { + const MAX_KEY_NAME_CHARS: usize = 128; + const MAX_SCOPES: usize = 8; + + payload.name = payload.name.trim().to_owned(); if payload.name.trim().is_empty() { return Err(ApiError::validation("key name is required")); } + if payload.name.chars().count() > MAX_KEY_NAME_CHARS { + return Err(ApiError::validation( + "key name must be at most 128 characters", + )); + } if payload.scopes.is_empty() { return Err(ApiError::validation("at least one key scope is required")); } + if payload.scopes.len() > MAX_SCOPES { + return Err(ApiError::validation("too many key scopes")); + } + if payload + .scopes + .iter() + .enumerate() + .any(|(index, scope)| payload.scopes[..index].contains(scope)) + { + return Err(ApiError::validation( + "key scopes must not contain duplicates", + )); + } let valid = payload.scopes.iter().all(|scope| match payload.key_kind { PlatformApiKeyKind::McpClient => matches!( @@ -156,6 +368,66 @@ fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result< "approval key can contain at most 20 allowed origins", )); } + if payload.key_kind == PlatformApiKeyKind::Approval { + let mut normalized_origins = Vec::with_capacity(payload.allowed_origins.len()); + for origin in &payload.allowed_origins { + normalized_origins.push(validate_approval_origin(origin)?); + } + if normalized_origins + .iter() + .enumerate() + .any(|(index, origin)| normalized_origins[..index].contains(origin)) + { + return Err(ApiError::validation( + "allowed origins must not contain duplicates", + )); + } + payload.allowed_origins = normalized_origins; + } else if !payload.allowed_origins.is_empty() { + return Err(ApiError::validation( + "allowed origins are only supported for approval keys", + )); + } - Ok(()) + let expires_at = payload + .expires_at + .as_deref() + .map(|value| { + OffsetDateTime::parse(value, &Rfc3339) + .map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp")) + }) + .transpose()?; + if expires_at.is_some_and(|value| value <= OffsetDateTime::now_utc()) { + return Err(ApiError::validation("expires_at must be in the future")); + } + + Ok(expires_at) +} + +fn validate_approval_origin(origin: &str) -> Result { + const MAX_ORIGIN_LEN: usize = 2048; + if origin.is_empty() || origin.len() > MAX_ORIGIN_LEN { + return Err(ApiError::validation("allowed origin is invalid")); + } + if origin + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err(ApiError::validation("allowed origin is invalid")); + } + + let parsed = + Url::parse(origin).map_err(|_| ApiError::validation("allowed origin is invalid"))?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(ApiError::validation("allowed origin is invalid")); + } + + Ok(parsed.origin().ascii_serialization()) } diff --git a/apps/admin-api/src/service/auth.rs b/apps/admin-api/src/service/auth.rs index a49f14b..f2323ff 100644 --- a/apps/admin-api/src/service/auth.rs +++ b/apps/admin-api/src/service/auth.rs @@ -1,20 +1,101 @@ -use crank_core::{LoginOutcome, MembershipRole, UserSessionId, WorkspaceId}; +use crank_core::{ + LoginOutcome, MembershipRole, User, UserId, UserSessionId, UserStatus, WorkspaceId, +}; +use crank_registry::{AdminSecurityAuditRequest, ConsumeAdminBootstrapContractRequest}; use serde_json::json; +use time::OffsetDateTime; use tracing::instrument; use crate::{ auth::{ - AuthenticatedSession, SessionCookie, create_session_cookie, hash_password, - hash_session_secret, verify_password, + AuthenticatedSession, SessionCookie, create_csrf_token_value, create_session_cookie, + hash_csrf_token, hash_password, hash_session_secret, verify_password, }, error::ApiError, service::{ - AdminService, ChangePasswordPayload, LoginPayload, SessionResponse, UpdateProfilePayload, - map_identity_error, validate_profile_display_name, validate_profile_email, + AdminService, BootstrapStatusResponse, ChangePasswordPayload, CompleteBootstrapPayload, + LoginPayload, SessionResponse, UpdateProfilePayload, hash_access_secret, + map_identity_error, new_prefixed_id, validate_profile_display_name, validate_profile_email, }, }; impl AdminService { + pub async fn bootstrap_status(&self) -> Result { + Ok(BootstrapStatusResponse { + bootstrap_required: !self.registry.has_password_admin().await?, + }) + } + + pub async fn complete_bootstrap( + &self, + payload: CompleteBootstrapPayload, + ) -> Result<(SessionCookie, SessionResponse), ApiError> { + if payload.token.len() < 32 || payload.token.len() > 256 { + self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap") + .await; + return Err(Self::constant_bootstrap_error()); + } + if payload.password.len() < 12 || payload.password.len() > 256 { + self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap") + .await; + return Err(Self::constant_bootstrap_error()); + } + + let now = OffsetDateTime::now_utc(); + let bootstrap_token_hash = hash_access_secret(&format!("bootstrap:{}", payload.token)); + if !self + .registry + .admin_bootstrap_contract_is_consumable(&bootstrap_token_hash, &now) + .await? + { + self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap") + .await; + return Err(Self::constant_bootstrap_error()); + } + + let password_hash = hash_password(&payload.password, &self.auth_settings.password_pepper)?; + let user_id = self + .registry + .consume_admin_bootstrap_contract(ConsumeAdminBootstrapContractRequest { + token_hash: &bootstrap_token_hash, + password_hash: &password_hash, + now: &now, + }) + .await + .map_err(|_| Self::constant_bootstrap_error()); + let user_id = match user_id { + Ok(user_id) => user_id, + Err(error) => { + self.record_admin_security_audit( + None, + "bootstrap_rejected", + "rejected", + "bootstrap", + ) + .await; + return Err(error); + } + }; + self.record_admin_security_audit( + Some(&user_id), + "bootstrap_completed", + "success", + "bootstrap", + ) + .await; + let user = self + .registry + .get_auth_user_by_id(&user_id) + .await? + .ok_or_else(|| ApiError::internal("bootstrap user was not found"))? + .user; + self.create_session_for_user(user).await + } + + fn constant_bootstrap_error() -> ApiError { + ApiError::unauthorized("bootstrap request is invalid or expired") + } + pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> { let password_hash = hash_password( &self.auth_settings.bootstrap_admin.password, @@ -83,36 +164,99 @@ impl AdminService { Ok(()) } + pub async fn verify_session_csrf( + &self, + session_id: &UserSessionId, + csrf_hash: &str, + ) -> Result { + Ok(self + .registry + .verify_session_csrf(session_id, csrf_hash) + .await?) + } + pub async fn login( &self, payload: LoginPayload, ) -> Result<(SessionCookie, SessionResponse), ApiError> { - let authenticated = self.authenticate_login(&payload).await?; + self.login_with_client_bucket(payload, "anonymous").await + } + pub async fn login_with_client_bucket( + &self, + payload: LoginPayload, + client_bucket: &str, + ) -> Result<(SessionCookie, SessionResponse), ApiError> { + let scope_hash = hash_access_secret(&format!( + "login:{}:{}", + client_bucket, + payload.email.trim().to_ascii_lowercase() + )); + if let Some(locked_until) = self + .registry + .login_backoff_locked_until(&scope_hash) + .await? + { + let retry_after_ms = (locked_until - OffsetDateTime::now_utc()) + .whole_milliseconds() + .clamp(1, 300_000); + return Err(ApiError::rate_limited_with_context( + "login temporarily unavailable", + json!({ + "retry_after_ms": retry_after_ms, + "error_code": "login_throttled", + "recovery": "retry_after_delay" + }), + )); + } + let authenticated = match self.authenticate_login(&payload).await { + Ok(authenticated) => authenticated, + Err(error) => { + let _ = self + .registry + .record_login_failure(&scope_hash, &OffsetDateTime::now_utc()) + .await; + self.record_admin_security_audit(None, "login_rejected", "rejected", "login") + .await; + return Err(error); + } + }; + self.registry.reset_login_backoff(&scope_hash).await?; + self.record_admin_security_audit( + Some(&authenticated.user.id), + "login_succeeded", + "success", + "login", + ) + .await; + self.create_session_for_user(authenticated.user).await + } + + async fn create_session_for_user( + &self, + user: User, + ) -> Result<(SessionCookie, SessionResponse), ApiError> { let session_cookie = create_session_cookie(&self.auth_settings)?; + let csrf_token = create_csrf_token_value(); let secret_hash = hash_session_secret( &session_cookie.session_id, &session_cookie.value, &self.auth_settings.session_secret, ); - let memberships = self - .registry - .list_workspaces_for_user(&authenticated.user.id) - .await?; + let csrf_hash = hash_csrf_token( + &session_cookie.session_id, + &csrf_token, + &self.auth_settings.session_secret, + ); + let memberships = self.registry.list_workspaces_for_user(&user.id).await?; let default_workspace_id = memberships .iter() .find(|membership| membership.workspace.id.as_str() == "ws_default") .map(|membership| membership.workspace.id.as_str().to_owned()); let current_workspace_id = default_workspace_id.or_else(|| { - authenticated - .current_workspace_id - .as_ref() - .map(|workspace_id| workspace_id.as_str().to_owned()) - .or_else(|| { - memberships - .first() - .map(|membership| membership.workspace.id.as_str().to_owned()) - }) + memberships + .first() + .map(|membership| membership.workspace.id.as_str().to_owned()) }); let current_workspace_ref = current_workspace_id .as_ref() @@ -120,9 +264,10 @@ impl AdminService { self.registry .create_user_session( &session_cookie.session_id, - &authenticated.user.id, + &user.id, current_workspace_ref.as_ref(), &secret_hash, + Some(&csrf_hash), &session_cookie.expires_at, ) .await?; @@ -130,9 +275,10 @@ impl AdminService { Ok(( session_cookie, SessionResponse { - user: authenticated.user, + user, memberships, current_workspace_id, + csrf_token, }, )) } @@ -158,7 +304,19 @@ impl AdminService { .registry .get_auth_user_by_email(&payload.email) .await? - .ok_or_else(|| ApiError::unauthorized("invalid email or password"))?; + .ok_or_else(|| { + let _ = hash_password(&payload.password, &self.auth_settings.password_pepper); + ApiError::unauthorized("invalid email or password") + })?; + + if user.user.status != UserStatus::Active { + let _ = verify_password( + &payload.password, + &self.auth_settings.password_pepper, + &user.password_hash, + ); + return Err(ApiError::unauthorized("invalid email or password")); + } if !verify_password( &payload.password, @@ -181,6 +339,8 @@ impl AdminService { _session_value: &str, ) -> Result<(), ApiError> { self.registry.revoke_user_session(session_id).await?; + self.record_admin_security_audit(None, "logout", "success", "session") + .await; Ok(()) } @@ -190,21 +350,23 @@ impl AdminService { session_id: &UserSessionId, session_value: &str, ) -> Result, ApiError> { - Ok(self - .get_session(session_id, session_value) - .await? - .map(|session| SessionResponse { - user: session.user, - memberships: session.memberships, - current_workspace_id: session - .current_workspace_id - .map(|id| id.as_str().to_owned()), - })) + let Some(session) = self.get_session(session_id, session_value).await? else { + return Ok(None); + }; + Ok(Some(SessionResponse { + user: session.user, + memberships: session.memberships, + current_workspace_id: session + .current_workspace_id + .map(|id| id.as_str().to_owned()), + csrf_token: String::new(), + })) } pub async fn update_profile( &self, user_id: &crank_core::UserId, + session_id: &UserSessionId, current_workspace_id: Option<&WorkspaceId>, payload: UpdateProfilePayload, ) -> Result { @@ -221,18 +383,19 @@ impl AdminService { user, memberships, current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()), + csrf_token: self.rotate_session_csrf_token(session_id).await?, }) } pub async fn change_password( &self, user_id: &crank_core::UserId, - current_session_id: &UserSessionId, + _current_session_id: &UserSessionId, payload: ChangePasswordPayload, ) -> Result<(), ApiError> { - if payload.new_password.len() < 12 { + if payload.new_password.len() < 12 || payload.new_password.len() > 256 { return Err(ApiError::validation( - "new password must be at least 12 characters long", + "new password must be between 12 and 256 characters long", )); } @@ -258,13 +421,48 @@ impl AdminService { let password_hash = hash_password(&payload.new_password, &self.auth_settings.password_pepper)?; self.registry - .update_user_password_and_revoke_other_sessions( - user_id, - current_session_id, - &password_hash, - ) + .update_user_password_and_revoke_all_sessions(user_id, &password_hash) .await?; + self.record_admin_security_audit(Some(user_id), "password_rotated", "success", "password") + .await; Ok(()) } + + async fn record_admin_security_audit( + &self, + actor_user_id: Option<&UserId>, + action: &'static str, + outcome: &'static str, + source: &'static str, + ) { + let (request_id, trace_id) = crank_observability::current_request_correlation(); + let audit_id = new_prefixed_id("audit"); + let _ = self + .registry + .record_admin_security_audit(AdminSecurityAuditRequest { + id: &audit_id, + action, + outcome, + actor_user_id, + session_id: None, + request_id: request_id.as_deref(), + trace_id: trace_id.as_deref(), + source, + }) + .await; + } + + pub async fn rotate_session_csrf_token( + &self, + session_id: &UserSessionId, + ) -> Result { + let csrf_token = create_csrf_token_value(); + let csrf_hash = + hash_csrf_token(session_id, &csrf_token, &self.auth_settings.session_secret); + self.registry + .update_user_session_csrf(session_id, &csrf_hash) + .await?; + Ok(csrf_token) + } } diff --git a/apps/admin-api/src/service/demo.rs b/apps/admin-api/src/service/demo.rs index 37d3852..8d12fa3 100644 --- a/apps/admin-api/src/service/demo.rs +++ b/apps/admin-api/src/service/demo.rs @@ -1,9 +1,9 @@ use std::collections::BTreeMap; use crank_core::{ - AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId, - OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, - Protocol, Target, WizardState, WorkspaceId, + AgentId, AgentOperationBinding, InvocationLevel, InvocationSource, InvocationStatus, + MembershipRole, OperationId, OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, + PlatformApiKeyStatus, Protocol, Target, WizardState, WorkspaceId, }; use crank_mapping::{JsonPathRoot, infer_mapping_from_samples}; use crank_mapping::{MappingRule, MappingSet}; @@ -87,8 +87,12 @@ impl AdminService { async fn cleanup_legacy_demo_assets(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> { for slug in ["revops-copilot", "support-triage"] { if let Some(agent) = self.find_agent_by_slug(workspace_id, slug).await? { - self.delete_agent(workspace_id, &AgentId::new(agent.id.as_str().to_owned())) - .await?; + self.delete_agent( + workspace_id, + &AgentId::new(agent.id.as_str().to_owned()), + None, + ) + .await?; } } @@ -129,7 +133,10 @@ impl AdminService { .await { Ok(()) => Ok(()), - Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => { + Err( + RegistryError::OperationHasPublishedAgentBindings { .. } + | RegistryError::OperationDeleteForbidden { .. }, + ) => { tracing::warn!( name: "admin.demo_operation.cleanup_skipped", operation_id = %operation_id.as_str(), @@ -168,6 +175,7 @@ impl AdminService { expires_at: None, allowed_origins: Vec::new(), }, + None, ) .await? .api_key @@ -175,7 +183,7 @@ impl AdminService { }; if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked { - self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id) + self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id, None) .await?; } @@ -210,8 +218,17 @@ impl AdminService { return Ok(()); } - self.publish_operation(workspace_id, &summary.id, summary.current_draft_version) - .await?; + self.publish_operation( + workspace_id, + &summary.id, + summary.current_draft_version, + &crank_registry::OperationStateExpectation { + current_draft_version: summary.current_draft_version, + status: summary.status, + latest_published_version: summary.latest_published_version, + }, + ) + .await?; Ok(()) } @@ -273,10 +290,15 @@ impl AdminService { publish: bool, ) -> Result<(), ApiError> { let summary = self.get_agent(workspace_id, agent_id).await?; - self.save_agent_bindings(workspace_id, agent_id, bindings.into()) + let current = self + .get_agent_version(workspace_id, agent_id, summary.current_draft_version) .await?; + if !demo_agent_bindings_match(¤t.bindings, &bindings) { + self.save_agent_bindings(workspace_id, agent_id, bindings.into(), None) + .await?; + } if publish && summary.latest_published_version.is_none() { - self.publish_agent(workspace_id, agent_id, summary.current_draft_version) + self.publish_agent(workspace_id, agent_id, summary.current_draft_version, None) .await?; } @@ -294,11 +316,16 @@ impl AdminService { .list_invocation_logs(ListInvocationLogsQuery { workspace_id, level: None, + status: None, + outcome_group: None, search_text: None, source: None, operation_id: None, agent_id: None, created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 1, }) .await? @@ -329,6 +356,10 @@ impl AdminService { message: "Frankfurter returned latest exchange rate".to_owned(), status_code: Some(200), error_kind: None, + execution_stage: Some(crank_core::ExecutionStage::Runtime), + execution_error_code: None, + retryability: Some(crank_core::Retryability::Never), + outcome_certainty: Some(crank_core::OutcomeCertainty::Certain), duration_ms: 124, request_preview: json!({ "path": {}, @@ -343,6 +374,25 @@ impl AdminService { } } +fn demo_agent_bindings_match( + existing: &[AgentOperationBinding], + desired: &[AgentBindingPayload], +) -> bool { + if existing.len() != desired.len() { + return false; + } + let mut desired = desired.iter().collect::>(); + desired.sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); + existing.iter().zip(desired).all(|(existing, desired)| { + existing.operation_id.as_str() == desired.operation_id + && existing.operation_version == desired.operation_version + && existing.tool_name == desired.tool_name + && existing.tool_title == desired.tool_title + && existing.tool_description_override == desired.tool_description_override + && existing.enabled == desired.enabled + }) +} + fn demo_currency_agent_payload() -> AgentPayload { AgentPayload { slug: "currency-rates".to_owned(), diff --git a/apps/admin-api/src/service/import_export.rs b/apps/admin-api/src/service/import_export.rs index 33e2271..8fd5303 100644 --- a/apps/admin-api/src/service/import_export.rs +++ b/apps/admin-api/src/service/import_export.rs @@ -1,16 +1,26 @@ -use crank_core::{ConfigExport, WorkspaceId}; -use crank_registry::RegistryOperation; +use crank_core::{OperationStatus, Target, WorkspaceId}; +use crank_registry::OperationStateExpectation; +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_yaml::Value as YamlValue; +use std::fmt; use tracing::{info, instrument}; use crate::{ error::ApiError, import_guidance::import_guidance_warnings, service::{ - AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse, NewVersionPayload, - OperationPayload, YamlOperationDocument, + AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse, + LegacyYamlOperationDocument, NewVersionPayload, OperationPayload, PortableOperation, + YamlOperationDocument, }, }; +const MAX_YAML_BYTES: usize = 256 * 1024; +const MAX_YAML_LINE_OR_SCALAR_BYTES: usize = 64 * 1024; +const MAX_YAML_DEPTH: usize = 64; +const MAX_YAML_NODES: usize = 20_000; +const MAX_YAML_COLLECTION_ITEMS: usize = 4_096; + impl AdminService { #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))] pub async fn export_operation( @@ -30,19 +40,30 @@ impl AdminService { let record = self .get_operation_version(workspace_id, operation_id, version) .await?; - let document = YamlOperationDocument { - format_version: "1".to_owned(), - kind: "operation".to_owned(), - operation: RegistryOperation { - config_export: Some(ConfigExport { - format_version: "1".to_owned(), - export_mode: query.mode, + let aggregate = self.get_operation(workspace_id, operation_id).await?; + let exportable = record.status == OperationStatus::Published + || (aggregate.status != OperationStatus::Archived + && record.status == OperationStatus::Draft + && aggregate.current_draft_version == version); + if !exportable { + return Err(ApiError::conflict_with_context( + "only the active current Draft or a Published Version can be exported", + serde_json::json!({ + "error_code": "operation_invalid_transition", + "version": version }), - ..record.snapshot - }, + )); + } + let operation = PortableOperation::from_registry(&record.snapshot); + reject_credential_material(&operation)?; + let document = YamlOperationDocument { + format_version: "2".to_owned(), + kind: "operation".to_owned(), + operation, }; - serde_yaml::to_string(&document).map_err(|error| ApiError::internal(error.to_string())) + serde_yaml::to_string(&document) + .map_err(|_| ApiError::internal("portable yaml serialization failed")) } #[instrument(skip(self, yaml_document), fields(mode = ?query.mode))] @@ -51,29 +72,41 @@ impl AdminService { workspace_id: &WorkspaceId, query: ImportQuery, yaml_document: &str, + if_match: Option<&str>, ) -> Result { - let document: YamlOperationDocument = serde_yaml::from_str(yaml_document) - .map_err(|error| ApiError::validation(error.to_string()))?; - if document.kind != "operation" { - return Err(ApiError::validation("yaml kind must be operation")); - } + reject_yaml_anchors_and_aliases(yaml_document)?; + let parsed = parse_bounded_yaml(yaml_document)?; + let format_version = parsed + .as_mapping() + .and_then(|mapping| mapping.get(YamlValue::String("format_version".to_owned()))) + .and_then(YamlValue::as_str) + .ok_or_else(yaml_invalid)?; - let payload = OperationPayload { - name: document.operation.name.clone(), - display_name: document.operation.display_name.clone(), - category: document.operation.category.clone(), - protocol: document.operation.protocol, - security_level: document.operation.security_level, - target: document.operation.target.clone(), - input_schema: document.operation.input_schema.clone(), - output_schema: document.operation.output_schema.clone(), - input_mapping: document.operation.input_mapping.clone(), - output_mapping: document.operation.output_mapping.clone(), - execution_config: document.operation.execution_config.clone(), - tool_description: document.operation.tool_description.clone(), - wizard_state: document.operation.wizard_state.clone(), + let (operation, warnings) = match format_version { + "2" => { + let document: YamlOperationDocument = + serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?; + if document.kind != "operation" || document.format_version != "2" { + return Err(yaml_unsupported()); + } + (document.operation, Vec::new()) + } + "1" => { + let document: LegacyYamlOperationDocument = + serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?; + if document.kind != "operation" || document.format_version != "1" { + return Err(yaml_unsupported()); + } + let warnings = import_guidance_warnings(&document.operation); + ( + PortableOperation::from_registry(&document.operation), + warnings, + ) + } + _ => return Err(yaml_unsupported()), }; - let warnings = import_guidance_warnings(&document.operation); + reject_credential_material(&operation)?; + let payload = operation.to_payload(); match query.mode { ImportMode::Create => { @@ -88,9 +121,66 @@ impl AdminService { } ImportMode::Upsert => { if let Some(existing) = self - .find_operation_by_name(workspace_id, &document.operation.name) + .find_operation_by_name(workspace_id, &operation.name) .await? { + if existing.status == OperationStatus::Archived { + return Err(crank_registry::RegistryError::OperationArchived { + operation_id: existing.id.as_str().to_owned(), + } + .into()); + } + let current = self + .get_operation_version( + workspace_id, + &existing.id, + existing.current_draft_version, + ) + .await?; + if operation.matches_registry(¤t.snapshot) { + let expected_state = OperationStateExpectation { + current_draft_version: existing.current_draft_version, + status: existing.status, + latest_published_version: existing.latest_published_version, + }; + self.registry + .verify_operation_state(workspace_id, &existing.id, &expected_state) + .await?; + return Ok(ImportResponse { + operation_id: existing.id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + version: existing.current_draft_version, + import_mode: ImportMode::Upsert, + warnings, + }); + } + let current_detail = self.get_operation(workspace_id, &existing.id).await?; + let expected_state = OperationStateExpectation { + current_draft_version: current_detail.current_draft_version, + status: current_detail.status, + latest_published_version: current_detail.latest_published_version, + }; + let expected_etag = Self::operation_state_etag(¤t_detail); + let provided_etag = if_match.ok_or_else(|| { + ApiError::precondition_required_with_context( + "If-Match is required when YAML upsert changes an existing Operation", + serde_json::json!({ + "error_code": "operation_precondition_required", + "current_version": existing.current_draft_version, + "recovery": "reload" + }), + ) + })?; + if provided_etag != expected_etag { + return Err(ApiError::conflict_with_context( + "Operation state changed; reload before retrying YAML upsert", + serde_json::json!({ + "error_code": "operation_stale_version", + "current_version": existing.current_draft_version, + "recovery": "reload" + }), + )); + } let created = self .create_version( workspace_id, @@ -99,9 +189,9 @@ impl AdminService { operation: payload, change_note: Some("yaml upsert".to_owned()), }, + &expected_state, ) .await?; - let response = ImportResponse { operation_id: created.operation_id, workspace_id: created.workspace_id, @@ -118,22 +208,384 @@ impl AdminService { Ok(response) } else { let created = self.create_operation(workspace_id, payload).await?; - let response = ImportResponse { + Ok(ImportResponse { operation_id: created.operation_id, workspace_id: created.workspace_id, version: created.version, import_mode: ImportMode::Upsert, warnings, - }; - info!( - name: "admin.operation.imported", - operation_id = %response.operation_id, - version = response.version, - "operation imported by upsert" - ); - Ok(response) + }) } } } } } + +impl PortableOperation { + fn from_registry(operation: &crank_registry::RegistryOperation) -> Self { + Self { + name: operation.name.clone(), + display_name: operation.display_name.clone(), + category: operation.category.clone(), + protocol: operation.protocol, + security_level: operation.security_level, + target: operation.target.clone(), + input_schema: operation.input_schema.clone(), + output_schema: operation.output_schema.clone(), + input_mapping: operation.input_mapping.clone(), + output_mapping: operation.output_mapping.clone(), + execution_config: operation.execution_config.clone(), + tool_description: operation.tool_description.clone(), + } + } + + fn to_payload(&self) -> OperationPayload { + OperationPayload { + name: self.name.clone(), + display_name: self.display_name.clone(), + category: self.category.clone(), + protocol: self.protocol, + security_level: self.security_level, + target: self.target.clone(), + input_schema: self.input_schema.clone(), + output_schema: self.output_schema.clone(), + input_mapping: self.input_mapping.clone(), + output_mapping: self.output_mapping.clone(), + execution_config: self.execution_config.clone(), + tool_description: self.tool_description.clone(), + wizard_state: None, + } + } + + fn matches_registry(&self, operation: &crank_registry::RegistryOperation) -> bool { + self.name == operation.name + && self.display_name == operation.display_name + && self.category == operation.category + && self.protocol == operation.protocol + && self.security_level == operation.security_level + && self.target == operation.target + && self.input_schema == operation.input_schema + && self.output_schema == operation.output_schema + && self.input_mapping == operation.input_mapping + && self.output_mapping == operation.output_mapping + && self.execution_config == operation.execution_config + && self.tool_description == operation.tool_description + } +} + +fn parse_bounded_yaml(document: &str) -> Result { + if document.is_empty() { + return Err(yaml_invalid()); + } + if document.len() > MAX_YAML_BYTES { + return Err(ApiError::payload_too_large_with_context( + "operation yaml exceeds the 256 KiB limit", + serde_json::json!({ "error_code": "operation_yaml_too_large" }), + )); + } + if document + .lines() + .any(|line| line.len() > MAX_YAML_LINE_OR_SCALAR_BYTES) + { + return Err(yaml_unsupported()); + } + let separators = document.lines().filter(|line| line.trim() == "---").count(); + if separators > 1 || document.lines().any(|line| line.trim() == "...") { + return Err(yaml_unsupported()); + } + DuplicateChecked::deserialize(serde_yaml::Deserializer::from_str(document)) + .map_err(|_| yaml_invalid())?; + let parsed: YamlValue = serde_yaml::from_str(document).map_err(|_| yaml_invalid())?; + let mut nodes = 0; + validate_yaml_value(&parsed, 0, &mut nodes)?; + Ok(parsed) +} + +/// A bounded preflight pass which rejects duplicate mapping keys before +/// `serde_yaml::Value` can collapse them. Values are deliberately discarded: +/// the authoritative typed parse follows only after this structural check. +struct DuplicateChecked; + +impl<'de> Deserialize<'de> for DuplicateChecked { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(DuplicateCheckedVisitor) + } +} + +struct DuplicateCheckedVisitor; + +impl<'de> Visitor<'de> for DuplicateCheckedVisitor { + type Value = DuplicateChecked; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a YAML value without duplicate mapping keys") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut keys = Vec::::new(); + while let Some(key) = map.next_key::()? { + if keys.iter().any(|existing| existing == &key) { + return Err(de::Error::custom("duplicate mapping key")); + } + keys.push(key); + map.next_value::()?; + } + Ok(DuplicateChecked) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + while sequence.next_element::()?.is_some() {} + Ok(DuplicateChecked) + } + + fn visit_bool(self, _: bool) -> Result { + Ok(DuplicateChecked) + } + + fn visit_i64(self, _: i64) -> Result { + Ok(DuplicateChecked) + } + + fn visit_u64(self, _: u64) -> Result { + Ok(DuplicateChecked) + } + + fn visit_f64(self, _: f64) -> Result { + Ok(DuplicateChecked) + } + + fn visit_str(self, _: &str) -> Result { + Ok(DuplicateChecked) + } + + fn visit_string(self, _: String) -> Result { + Ok(DuplicateChecked) + } + + fn visit_none(self) -> Result { + Ok(DuplicateChecked) + } + + fn visit_unit(self) -> Result { + Ok(DuplicateChecked) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + DuplicateChecked::deserialize(deserializer) + } + + fn visit_newtype_struct(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + DuplicateChecked::deserialize(deserializer) + } +} + +fn validate_yaml_value(value: &YamlValue, depth: usize, nodes: &mut usize) -> Result<(), ApiError> { + if depth > MAX_YAML_DEPTH { + return Err(yaml_unsupported()); + } + *nodes = nodes.saturating_add(1); + if *nodes > MAX_YAML_NODES { + return Err(yaml_unsupported()); + } + match value { + YamlValue::Sequence(values) => { + if values.len() > MAX_YAML_COLLECTION_ITEMS { + return Err(yaml_unsupported()); + } + for value in values { + validate_yaml_value(value, depth + 1, nodes)?; + } + } + YamlValue::Mapping(values) => { + if values.len() > MAX_YAML_COLLECTION_ITEMS { + return Err(yaml_unsupported()); + } + for (key, value) in values { + validate_yaml_value(key, depth + 1, nodes)?; + validate_yaml_value(value, depth + 1, nodes)?; + } + } + YamlValue::String(value) if value.len() > MAX_YAML_LINE_OR_SCALAR_BYTES => { + return Err(yaml_unsupported()); + } + YamlValue::Tagged(_) => return Err(yaml_unsupported()), + _ => {} + } + Ok(()) +} + +fn reject_credential_material(operation: &PortableOperation) -> Result<(), ApiError> { + let sensitive_name = |name: &str| { + let normalized = name.to_ascii_lowercase(); + let segments = normalized.split(['-', '_', '.']).collect::>(); + matches!( + normalized.as_str(), + "authorization" | "proxy-authorization" | "cookie" | "set-cookie" + ) || segments + .iter() + .any(|segment| matches!(*segment, "auth" | "token" | "secret" | "cookie")) + || segments.windows(2).any(|parts| parts == ["api", "key"]) + }; + let sensitive_value = |value: &str| { + let normalized = value.trim().to_ascii_lowercase(); + normalized.starts_with("bearer ") + || normalized.starts_with("basic ") + || normalized.starts_with("digest ") + || normalized.contains("api_key=") + || normalized.contains("access_token=") + }; + let target_headers = match &operation.target { + Target::Rest(target) => &target.static_headers, + }; + if target_headers + .iter() + .any(|(name, value)| sensitive_name(name) || sensitive_value(value)) + || operation + .execution_config + .headers + .iter() + .any(|(name, value)| sensitive_name(name) || sensitive_value(value)) + { + return Err(ApiError::unprocessable_with_context( + "portable yaml contains credential-bearing headers", + serde_json::json!({ "error_code": "operation_yaml_invalid" }), + )); + } + Ok(()) +} + +fn reject_yaml_anchors_and_aliases(document: &str) -> Result<(), ApiError> { + let mut single_quote = false; + let mut double_quote = false; + let mut escaped = false; + let mut comment = false; + let mut previous = '\n'; + let chars = document.chars().collect::>(); + for (index, character) in chars.iter().copied().enumerate() { + if character == '\n' { + comment = false; + previous = character; + continue; + } + if comment { + previous = character; + continue; + } + if double_quote { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + double_quote = false; + } + previous = character; + continue; + } + if single_quote { + if character == '\'' { + single_quote = false; + } + previous = character; + continue; + } + match character { + '#' => comment = true, + '"' => double_quote = true, + '\'' => single_quote = true, + '&' | '*' + if (previous.is_whitespace() || matches!(previous, ':' | '[' | '{' | ',')) + && chars.get(index + 1).is_some_and(|next| { + next.is_ascii_alphanumeric() || matches!(next, '_' | '-') + }) => + { + return Err(yaml_unsupported()); + } + _ => {} + } + previous = character; + } + Ok(()) +} + +fn yaml_invalid() -> ApiError { + ApiError::unprocessable_with_context( + "operation yaml is invalid", + serde_json::json!({ "error_code": "operation_yaml_invalid" }), + ) +} + +fn yaml_unsupported() -> ApiError { + ApiError::unprocessable_with_context( + "operation yaml uses an unsupported construct", + serde_json::json!({ "error_code": "operation_yaml_unsupported" }), + ) +} + +#[cfg(test)] +mod tests { + use super::parse_bounded_yaml; + use serde_json::Value; + + #[test] + fn portable_v2_schema_freezes_the_closed_top_level_contract() { + let schema: Value = serde_json::from_str(include_str!( + "../../../../docs/schemas/operation-export-v2.schema.json" + )) + .unwrap(); + assert_eq!(schema["properties"]["format_version"]["const"], "2"); + assert_eq!(schema["properties"]["kind"]["const"], "operation"); + assert_eq!(schema["additionalProperties"], false); + assert_eq!( + schema["$defs"]["portable_operation"]["additionalProperties"], + false + ); + assert_eq!( + schema["$defs"]["portable_operation"]["properties"]["security_level"]["enum"], + serde_json::json!(["standard"]) + ); + for name in [ + "rest_target", + "schema", + "mapping_set", + "mapping_rule", + "execution_config", + "tool_description", + ] { + assert_eq!(schema["$defs"][name]["additionalProperties"], false); + } + assert_eq!( + schema["$defs"]["portable_operation"]["properties"]["target"]["$ref"], + "#/$defs/rest_target" + ); + } + + #[test] + fn bounded_yaml_rejects_duplicate_keys_before_typed_parsing() { + let document = + "format_version: '2'\nkind: operation\nkind: attacker_override\noperation: {}\n"; + let error = parse_bounded_yaml(document).expect_err("duplicate key must fail closed"); + match error { + crate::error::ApiError::Unprocessable { context, .. } => assert_eq!( + context.and_then(|value| value["error_code"].as_str().map(str::to_owned)), + Some("operation_yaml_invalid".to_owned()) + ), + other => panic!("unexpected error: {other:?}"), + } + } +} diff --git a/apps/admin-api/src/service/observability.rs b/apps/admin-api/src/service/observability.rs index 8837fb2..661ef0a 100644 --- a/apps/admin-api/src/service/observability.rs +++ b/apps/admin-api/src/service/observability.rs @@ -1,28 +1,52 @@ +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod, WorkspaceId, }; use crank_registry::{ - ApprovalRequestRecord, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery, - ListInvocationLogsQuery, UsageQuery, UsageRollupRecord, + ApprovalRequestRecord, DecideApprovalRequest, ExpireApprovalRequest, InvocationLogRecord, + InvocationRetentionOutcome, ListApprovalRequestsQuery, ListInvocationLogsQuery, UsageQuery, + UsageRollupRecord, }; use serde_json::json; -use time::OffsetDateTime; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::instrument; use crate::{ error::ApiError, service::{ - AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery, - usage_window, + AdminService, ApprovalDecisionPayload, ApprovalsQuery, LogsListResponse, LogsQuery, + UsageOverviewResponse, UsageRequestQuery, usage_window, }, }; +const LOG_LIST_DEFAULT_LIMIT: u32 = 100; +const LOG_LIST_MAX_LIMIT: u32 = 200; +const LOG_CSV_MAX_ROWS: u32 = 1_000; +const MAX_EXPLICIT_USAGE_WINDOW_DAYS: i64 = 90; + +#[derive(serde::Serialize, serde::Deserialize)] +struct LogsCursor { + created_at: String, + id: String, +} + +fn safe_approval_record(mut record: ApprovalRequestRecord) -> ApprovalRequestRecord { + record.approval.request_payload = + crank_core::sanitize_invocation_preview(&record.approval.request_payload); + record.approval.response_payload = record + .approval + .response_payload + .as_ref() + .map(crank_core::sanitize_invocation_preview); + record +} + impl AdminService { pub async fn cleanup_invocation_logs_before( &self, cutoff: OffsetDateTime, - ) -> Result { + ) -> Result { self.registry .delete_invocation_logs_before(cutoff) .await @@ -34,25 +58,45 @@ impl AdminService { &self, workspace_id: &WorkspaceId, query: LogsQuery, - ) -> Result, ApiError> { + ) -> Result { self.ensure_workspace_exists(workspace_id).await?; let operation_id = query.operation_id.as_deref().map(OperationId::new); let agent_id = query.agent_id.as_deref().map(AgentId::new); - let (_, created_after, _) = usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?; + let (_, created_after, created_before, _) = + resolve_usage_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?; + let (cursor_created_at, cursor_id) = decode_logs_cursor(query.cursor.as_deref())?; + let limit = query + .limit + .unwrap_or(LOG_LIST_DEFAULT_LIMIT) + .clamp(1, LOG_LIST_MAX_LIMIT); - self.registry + let mut items = self + .registry .list_invocation_logs(ListInvocationLogsQuery { workspace_id, level: query.level, + status: query.status, + outcome_group: query.outcome_group, search_text: query.search.as_deref(), source: query.source, operation_id: operation_id.as_ref(), agent_id: agent_id.as_ref(), created_after: Some(&created_after), - limit: query.limit.unwrap_or(100), + created_before: Some(&created_before), + cursor_created_at: cursor_created_at.as_deref(), + cursor_id: cursor_id.as_ref(), + limit: limit.saturating_add(1), }) - .await - .map_err(ApiError::from) + .await?; + + let next_cursor = if items.len() > limit as usize { + items.truncate(limit as usize); + items.last().map(encode_logs_cursor).transpose()? + } else { + None + }; + + Ok(LogsListResponse { items, next_cursor }) } #[instrument(skip(self))] @@ -72,6 +116,69 @@ impl AdminService { }) } + #[instrument(skip(self))] + pub async fn export_logs_csv( + &self, + workspace_id: &WorkspaceId, + mut query: LogsQuery, + ) -> Result { + query.limit = Some( + query + .limit + .unwrap_or(LOG_CSV_MAX_ROWS) + .clamp(1, LOG_CSV_MAX_ROWS), + ); + query.cursor = None; + let page = self.list_logs(workspace_id, query).await?; + let mut csv = String::from( + "created_at,level,status,source,agent,operation,operation_version,duration_ms,request_id,trace_id,stage,error_code,message,request_preview,response_preview\n", + ); + for record in page.items { + let log = record.log; + let row = [ + log.created_at + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string()))?, + serialize_csv_text(log.level)?, + serialize_csv_text(log.status)?, + serialize_csv_text(log.source)?, + record + .agent_display_name + .or(record.agent_slug) + .unwrap_or_default(), + record.operation_display_name, + log.operation_version + .map(|value| value.to_string()) + .unwrap_or_default(), + log.duration_ms.to_string(), + log.request_id.unwrap_or_default(), + log.trace_id.unwrap_or_default(), + log.execution_stage + .map(serialize_csv_text) + .transpose()? + .unwrap_or_default(), + log.execution_error_code + .map(serialize_csv_text) + .transpose()? + .unwrap_or_default(), + log.message, + serde_json::to_string(&log.request_preview) + .map_err(|error| ApiError::internal(error.to_string()))?, + serde_json::to_string(&log.response_preview) + .map_err(|error| ApiError::internal(error.to_string()))?, + ]; + csv.push_str(&row.into_iter().map(csv_cell).collect::>().join(",")); + csv.push('\n'); + if csv.len() > 1_048_576 { + return Err(ApiError::unprocessable_with_context( + "logs CSV export exceeded the bounded response size", + json!({ "error_code": "logs_csv_too_large" }), + )); + } + } + Ok(csv) + } + #[instrument(skip(self))] pub async fn list_approvals( &self, @@ -92,7 +199,7 @@ impl AdminService { for record in records { let record = self.normalize_approval_record(record).await?; if query.status.is_none() || record.approval.status == query.status.unwrap() { - normalized.push(record); + normalized.push(safe_approval_record(record)); } } @@ -117,7 +224,114 @@ impl AdminService { ) })?; - self.normalize_approval_record(record).await + self.normalize_approval_record(record) + .await + .map(safe_approval_record) + } + + #[instrument(skip(self, payload))] + pub async fn approve_approval( + &self, + workspace_id: &WorkspaceId, + approval_id: &ApprovalRequestId, + payload: ApprovalDecisionPayload, + ) -> Result { + if !payload.approve.eq_ignore_ascii_case("yes") { + return Err(ApiError::unprocessable_with_context( + "approve must be yes for approval confirmation", + json!({ "error_code": "invalid_decision_payload" }), + )); + } + self.decide_admin_approval( + workspace_id, + approval_id, + ApprovalRequestStatus::Approved, + Some(json!({"approve": "yes"})), + payload.note.as_deref(), + ) + .await + } + + #[instrument(skip(self, payload))] + pub async fn deny_approval( + &self, + workspace_id: &WorkspaceId, + approval_id: &ApprovalRequestId, + payload: ApprovalDecisionPayload, + ) -> Result { + if !payload.approve.eq_ignore_ascii_case("no") { + return Err(ApiError::unprocessable_with_context( + "approve must be no for approval denial", + json!({ "error_code": "invalid_decision_payload" }), + )); + } + self.decide_admin_approval( + workspace_id, + approval_id, + ApprovalRequestStatus::Denied, + Some(json!({"approve": "no"})), + payload.note.as_deref(), + ) + .await + } + + async fn decide_admin_approval( + &self, + workspace_id: &WorkspaceId, + approval_id: &ApprovalRequestId, + status: ApprovalRequestStatus, + response_payload: Option, + decision_note: Option<&str>, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let current = self + .registry + .get_approval_request(workspace_id, approval_id) + .await? + .ok_or_else(|| { + ApiError::not_found_with_context( + format!("approval request {} was not found", approval_id.as_str()), + json!({ "approval_id": approval_id.as_str() }), + ) + })?; + let normalized = self.normalize_approval_record(current).await?; + if normalized.approval.status != ApprovalRequestStatus::Pending { + return Err(ApiError::conflict_with_context( + "approval request is not pending", + json!({ + "approval_id": approval_id.as_str(), + "status": normalized.approval.status, + "error_code": "approval_state_conflict" + }), + )); + } + let decided = self + .registry + .decide_approval_request(DecideApprovalRequest { + workspace_id, + agent_id: &normalized.approval.agent_id, + approval_id, + operation_id: &normalized.approval.operation_id, + operation_version: normalized.approval.operation_version, + request_payload: &normalized.approval.request_payload, + status, + decided_at: OffsetDateTime::now_utc(), + decided_by_key_id: None, + response_payload, + decision_note, + }) + .await? + .ok_or_else(|| { + ApiError::conflict_with_context( + "approval request state changed before decision was recorded", + json!({ + "approval_id": approval_id.as_str(), + "error_code": "approval_state_conflict" + }), + ) + })?; + + Ok(safe_approval_record(decided)) } async fn normalize_approval_record( @@ -149,13 +363,14 @@ impl AdminService { query: UsageRequestQuery, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; - let (period, created_after, bucket) = - usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?; + let (period, created_after, created_before, bucket) = + resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?; let usage_query = UsageQuery { workspace_id, period, source: query.source, created_after: &created_after, + created_before: &created_before, bucket, }; @@ -168,16 +383,104 @@ impl AdminService { .registry .list_usage_by_operation(usage_query.clone()) .await?; - let agents = self.registry.list_usage_by_agent(usage_query).await?; + let agents = self + .registry + .list_usage_by_agent(usage_query.clone()) + .await?; + let outcomes = self.registry.list_usage_outcomes(usage_query).await?; Ok(UsageOverviewResponse { summary, timeline, operations, agents, + outcomes, }) } + #[instrument(skip(self))] + pub async fn export_usage_csv( + &self, + workspace_id: &WorkspaceId, + query: UsageRequestQuery, + ) -> Result { + let usage = self.get_usage_overview(workspace_id, query).await?; + let mut csv = String::from( + "kind,name,group,error_code,calls_total,calls_error,p50_ms,p95_ms,p99_ms\n", + ); + for operation in usage.operations { + csv.push_str( + &[ + "operation".to_owned(), + operation.operation_display_name, + String::new(), + String::new(), + operation.calls_total.to_string(), + operation.calls_error.to_string(), + operation.p50_ms.to_string(), + operation.p95_ms.to_string(), + operation.p99_ms.to_string(), + ] + .into_iter() + .map(csv_cell) + .collect::>() + .join(","), + ); + csv.push('\n'); + } + for agent in usage.agents { + csv.push_str( + &[ + "agent".to_owned(), + agent.agent_display_name, + String::new(), + String::new(), + agent.calls_total.to_string(), + agent.calls_error.to_string(), + agent.p50_ms.to_string(), + agent.p95_ms.to_string(), + agent.p99_ms.to_string(), + ] + .into_iter() + .map(csv_cell) + .collect::>() + .join(","), + ); + csv.push('\n'); + } + for outcome in usage.outcomes { + csv.push_str( + &[ + "outcome".to_owned(), + String::new(), + serialize_csv_text(outcome.group)?, + outcome + .execution_error_code + .map(serialize_csv_text) + .transpose()? + .unwrap_or_default(), + outcome.calls_total.to_string(), + String::new(), + outcome.p50_ms.to_string(), + outcome.p95_ms.to_string(), + outcome.p99_ms.to_string(), + ] + .into_iter() + .map(csv_cell) + .collect::>() + .join(","), + ); + csv.push('\n'); + } + if csv.len() > 1_048_576 { + return Err(ApiError::unprocessable_with_context( + "usage CSV export exceeded the bounded response size", + json!({ "error_code": "usage_csv_too_large" }), + )); + } + Ok(csv) + } + #[instrument(skip(self))] pub async fn get_operation_usage( &self, @@ -186,8 +489,8 @@ impl AdminService { query: UsageRequestQuery, ) -> Result { self.get_operation(workspace_id, operation_id).await?; - let (period, created_after, bucket) = - usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?; + let (period, created_after, created_before, bucket) = + resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?; self.registry .get_usage_for_operation( @@ -196,6 +499,7 @@ impl AdminService { period, source: query.source, created_after: &created_after, + created_before: &created_before, bucket, }, operation_id, @@ -220,8 +524,8 @@ impl AdminService { query: UsageRequestQuery, ) -> Result { self.get_agent(workspace_id, agent_id).await?; - let (period, created_after, bucket) = - usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?; + let (period, created_after, created_before, bucket) = + resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?; self.registry .get_usage_for_agent( @@ -230,6 +534,7 @@ impl AdminService { period, source: query.source, created_after: &created_after, + created_before: &created_before, bucket, }, agent_id, @@ -243,3 +548,143 @@ impl AdminService { }) } } + +fn resolve_usage_window( + period: UsagePeriod, + query: &LogsQuery, +) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> { + resolve_explicit_or_period_window( + period, + query.created_after.as_deref(), + query.created_before.as_deref(), + ) +} + +fn resolve_usage_request_window( + period: UsagePeriod, + query: &UsageRequestQuery, +) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> { + resolve_explicit_or_period_window( + period, + query.created_after.as_deref(), + query.created_before.as_deref(), + ) +} + +fn resolve_explicit_or_period_window( + period: UsagePeriod, + created_after: Option<&str>, + created_before: Option<&str>, +) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> { + match (created_after, created_before) { + (None, None) => usage_window(period), + (Some(_), None) | (None, Some(_)) => Err(invalid_usage_window( + "usage window requires both created_after and created_before", + )), + (Some(after), Some(before)) => { + let after = OffsetDateTime::parse(after, &Rfc3339) + .map_err(|_| invalid_usage_window("created_after must be RFC3339 UTC"))?; + let before = OffsetDateTime::parse(before, &Rfc3339) + .map_err(|_| invalid_usage_window("created_before must be RFC3339 UTC"))?; + if after >= before { + return Err(invalid_usage_window( + "created_after must be before created_before", + )); + } + let duration = before - after; + if duration > time::Duration::days(MAX_EXPLICIT_USAGE_WINDOW_DAYS) { + return Err(invalid_usage_window("usage window exceeds maximum range")); + } + let bucket = if duration <= time::Duration::days(1) { + crank_registry::UsageBucket::Hour + } else if duration <= time::Duration::days(14) { + crank_registry::UsageBucket::Day + } else if duration <= time::Duration::days(45) { + crank_registry::UsageBucket::Week + } else { + crank_registry::UsageBucket::Month + }; + Ok(( + period, + after + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string()))?, + before + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string()))?, + bucket, + )) + } + } +} + +fn invalid_usage_window(message: &'static str) -> ApiError { + ApiError::unprocessable_with_context(message, json!({ "error_code": "usage_window_invalid" })) +} + +fn decode_logs_cursor( + cursor: Option<&str>, +) -> Result<(Option, Option), ApiError> { + let Some(cursor) = cursor else { + return Ok((None, None)); + }; + if cursor.len() > 512 { + return Err(invalid_logs_cursor()); + } + let bytes = URL_SAFE_NO_PAD + .decode(cursor) + .map_err(|_| invalid_logs_cursor())?; + let decoded: LogsCursor = serde_json::from_slice(&bytes).map_err(|_| invalid_logs_cursor())?; + OffsetDateTime::parse(&decoded.created_at, &Rfc3339).map_err(|_| invalid_logs_cursor())?; + if decoded.id.is_empty() + || decoded.id.len() > 128 + || decoded.id.contains(';') + || decoded.id.contains(',') + { + return Err(invalid_logs_cursor()); + } + Ok(( + Some(decoded.created_at), + Some(InvocationLogId::new(decoded.id)), + )) +} + +fn encode_logs_cursor(record: &InvocationLogRecord) -> Result { + let cursor = LogsCursor { + created_at: record + .log + .created_at + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string()))?, + id: record.log.id.as_str().to_owned(), + }; + let encoded = + serde_json::to_vec(&cursor).map_err(|error| ApiError::internal(error.to_string()))?; + Ok(URL_SAFE_NO_PAD.encode(encoded)) +} + +fn invalid_logs_cursor() -> ApiError { + ApiError::unprocessable_with_context( + "logs cursor is invalid", + json!({ "error_code": "logs_cursor_invalid" }), + ) +} + +fn serialize_csv_text(value: T) -> Result { + serde_json::to_value(value) + .map_err(|error| ApiError::internal(error.to_string()))? + .as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| ApiError::internal("failed to serialize CSV enum value")) +} + +fn csv_cell(mut value: String) -> String { + if value + .chars() + .next() + .is_some_and(|ch| matches!(ch, '=' | '+' | '-' | '@' | '\t' | '\r' | '\n')) + { + value.insert(0, '\''); + } + format!("\"{}\"", value.replace('"', "\"\"")) +} diff --git a/apps/admin-api/src/service/onboarding.rs b/apps/admin-api/src/service/onboarding.rs new file mode 100644 index 0000000..9fd1da2 --- /dev/null +++ b/apps/admin-api/src/service/onboarding.rs @@ -0,0 +1,268 @@ +use crank_core::{ + OnboardingProjection, OnboardingStepId, ProductEventId, ProductEventKind, WorkspaceId, +}; +use crank_registry::{ + OnboardingPresentationMilestone, RecordOnboardingMilestoneRequest, RegistryError, +}; +use serde_json::json; +use time::OffsetDateTime; + +use crate::{ + error::ApiError, + service::{ + AdminService, OnboardingEventPayload, OnboardingEventResponse, OnboardingFirstCallEvidence, + OnboardingResponse, OnboardingStepView, ResetOnboardingSelectionResponse, format_timestamp, + new_prefixed_id, + }, +}; + +impl AdminService { + pub async fn get_onboarding( + &self, + workspace_id: &WorkspaceId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let projection = self + .registry + .ensure_onboarding_eligibility(workspace_id, OffsetDateTime::now_utc()) + .await?; + self.map_onboarding_response(projection).await + } + + pub async fn record_onboarding_event( + &self, + workspace_id: &WorkspaceId, + payload: OnboardingEventPayload, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + if payload.completed_steps.is_some() || payload.event == "completed" { + return Err(onboarding_event_not_allowed()); + } + if payload.idempotency_key.is_empty() || payload.idempotency_key.len() > 256 { + return Err(ApiError::validation( + "idempotency_key must contain 1..256 bytes", + )); + } + if payload.idempotency_key.starts_with("onboarding:") { + return Err(onboarding_event_not_allowed()); + } + let (milestone, event_kind) = match payload.event.as_str() { + "started" => ( + OnboardingPresentationMilestone::Started, + ProductEventKind::OnboardingStarted, + ), + "resumed" => ( + OnboardingPresentationMilestone::Resumed, + ProductEventKind::OnboardingResumed, + ), + "dismissed" => ( + OnboardingPresentationMilestone::Dismissed, + ProductEventKind::OnboardingDismissed, + ), + "abandoned" => ( + OnboardingPresentationMilestone::Abandoned, + ProductEventKind::OnboardingAbandoned, + ), + _ => return Err(onboarding_event_not_allowed()), + }; + // Presentation events must never exist outside the server-owned cohort, + // including a direct API call before the first UI snapshot GET. + let current = self + .registry + .ensure_onboarding_eligibility(workspace_id, OffsetDateTime::now_utc()) + .await?; + let outcome = self + .registry + .record_onboarding_milestone(RecordOnboardingMilestoneRequest { + workspace_id, + event_id: &ProductEventId::new(new_prefixed_id("pe")), + milestone, + idempotency_key: &payload.idempotency_key, + expected_revision: payload.expected_revision, + occurred_at: OffsetDateTime::now_utc(), + eligible_since: None, + }) + .await; + let outcome = match outcome { + Ok(outcome) => outcome, + Err(RegistryError::OnboardingStaleRevision) => { + let replay = self + .registry + .get_product_event_by_idempotency_key(workspace_id, &payload.idempotency_key) + .await?; + if replay + .as_ref() + .is_some_and(|record| record.event.kind == event_kind) + { + return Ok(OnboardingEventResponse { + accepted: false, + onboarding: self.map_onboarding_response(current).await?, + }); + } + if replay.is_some() { + return Err(ApiError::conflict_with_context( + "idempotency key was already used for another onboarding event", + json!({"error_code":"onboarding_idempotency_conflict","recovery":"use_original_event"}), + )); + } + return Err(ApiError::conflict_with_context( + "onboarding state changed; reload before retrying", + json!({"error_code":"onboarding_stale_revision","recovery":"reload"}), + )); + } + Err(RegistryError::InvalidExecutionRecord { + field: "product_event.idempotency_conflict", + }) => { + return Err(ApiError::conflict_with_context( + "idempotency key was already used for another onboarding event", + json!({"error_code":"onboarding_idempotency_conflict","recovery":"use_original_event"}), + )); + } + Err(other) => return Err(other.into()), + }; + Ok(OnboardingEventResponse { + accepted: outcome.accepted, + onboarding: self.map_onboarding_response(outcome.projection).await?, + }) + } + + pub async fn reset_onboarding_selection( + &self, + workspace_id: &WorkspaceId, + expected_revision: i64, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let projection = self + .registry + .reset_onboarding_selection(workspace_id, expected_revision, OffsetDateTime::now_utc()) + .await + .map_err(|error| match error { + RegistryError::OnboardingStaleRevision => ApiError::conflict_with_context( + "onboarding state changed; reload before resetting the selection", + json!({"error_code":"onboarding_stale_revision","recovery":"reload"}), + ), + other => ApiError::from(other), + })?; + Ok(ResetOnboardingSelectionResponse { + selection_reset: true, + onboarding: self.map_onboarding_response(projection).await?, + }) + } + + async fn map_onboarding_response( + &self, + projection: OnboardingProjection, + ) -> Result { + let endpoint = if let Some(agent_id) = projection.agent_id.as_ref() { + let workspace = self.get_workspace(&projection.workspace_id).await?; + let agent = self + .registry + .get_agent_summary(&projection.workspace_id, agent_id) + .await? + .ok_or_else(|| ApiError::internal("onboarding agent disappeared"))?; + Some(self.public_agent_mcp_endpoint(&workspace.workspace.slug, &agent.slug)) + } else { + None + }; + let first_call = match ( + projection.first_call_log_id.as_ref(), + projection.agent_id.as_ref(), + projection.platform_api_key_id.as_ref(), + projection.operation_id.as_ref(), + projection.operation_version, + projection.first_call_tool_name.as_ref(), + projection.first_call_at, + ) { + ( + Some(log_id), + Some(agent_id), + Some(key_id), + Some(operation_id), + Some(operation_version), + Some(tool_name), + Some(occurred_at), + ) => Some(OnboardingFirstCallEvidence { + log_id: log_id.as_str().to_owned(), + agent_id: agent_id.as_str().to_owned(), + key_id: key_id.as_str().to_owned(), + operation_id: operation_id.as_str().to_owned(), + operation_version, + tool_name: tool_name.clone(), + occurred_at: format_timestamp(occurred_at), + request_id: projection.first_call_request_id.clone(), + trace_id: projection.first_call_trace_id.clone(), + }), + _ => None, + }; + let first_incomplete = projection.steps.iter().position(|step| !step.completed); + Ok(OnboardingResponse { + schema_version: 1, + workspace_id: projection.workspace_id.as_str().to_owned(), + revision: projection.revision, + status: if projection.completed { + "complete".to_owned() + } else { + "in_progress".to_owned() + }, + completed: projection.completed, + eligible_since: projection.eligible_since.map(format_timestamp), + steps: projection + .steps + .iter() + .enumerate() + .map(|(index, step)| { + let status = if step.completed { + "complete" + } else if projection.was_completed { + "regressed" + } else if Some(index) == first_incomplete { + "current" + } else { + "pending" + }; + let reason_code = match status { + "complete" => "authoritative_evidence_present", + "regressed" => "authoritative_evidence_regressed", + "current" => "authoritative_evidence_missing", + _ => "prerequisite_incomplete", + }; + OnboardingStepView { + id: step.id, + completed: step.completed, + status: status.to_owned(), + action_code: action_code(step.id).to_owned(), + reason_code: reason_code.to_owned(), + } + }) + .collect(), + operation_id: projection.operation_id.map(|id| id.as_str().to_owned()), + operation_version: projection.operation_version, + agent_id: projection.agent_id.map(|id| id.as_str().to_owned()), + catalog_revision: projection.catalog_revision, + platform_api_key_id: projection + .platform_api_key_id + .map(|id| id.as_str().to_owned()), + mcp_endpoint: endpoint, + first_call, + }) + } +} + +fn action_code(step: OnboardingStepId) -> &'static str { + match step { + OnboardingStepId::Operation => "create_operation", + OnboardingStepId::Test => "test_operation", + OnboardingStepId::PublishOperation => "publish_operation", + OnboardingStepId::Agent => "publish_agent", + OnboardingStepId::Key => "create_mcp_key", + OnboardingStepId::McpConnection => "connect_mcp_client", + OnboardingStepId::FirstCall => "call_tool", + } +} + +fn onboarding_event_not_allowed() -> ApiError { + ApiError::unprocessable_with_context( + "client cannot complete authoritative onboarding steps", + json!({"error_code":"onboarding_event_not_allowed"}), + ) +} diff --git a/apps/admin-api/src/service/operations.rs b/apps/admin-api/src/service/operations.rs index c1bf878..bc96435 100644 --- a/apps/admin-api/src/service/operations.rs +++ b/apps/admin-api/src/service/operations.rs @@ -1,14 +1,17 @@ use crank_core::{ - ConfigExport, ExecutionMode, ExportMode, InvocationLevel, InvocationSource, InvocationStatus, - OperationId, OperationStatus, Samples, WorkspaceId, + ConfigExport, ExecutionMode, ExecutionOrigin, ExportMode, InvocationLevel, InvocationSource, + InvocationStatus, OperationAvailability, OperationId, OperationStatus, OperationVersionState, + Samples, WorkspaceId, }; use crank_registry::{ - CreateVersionRequest, OperationVersionRecord, PublishRequest, RegistryOperation, + CreateVersionRequest, OperationStateExpectation, OperationVersionRecord, PublishRequest, + RegistryOperation, }; use crank_runtime::{ - RuntimeError, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext, + ExecutionAuthorization, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext, }; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use time::OffsetDateTime; use tracing::{info, instrument}; @@ -18,13 +21,33 @@ use crate::{ AdminService, CreatedOperationResponse, InvocationRecordRequest, NewVersionPayload, OperationDetailView, OperationMutationResult, OperationPayload, OperationSummaryView, PublishResponse, TestRunPayload, TestRunResult, UpdateOperationPayload, VersionRef, - agent_ref_map, build_request_preview, default_usage_summary, enrich_operation_summary, - format_timestamp, new_prefixed_id, now_string, runtime_error_code, today_start_utc, - tool_quality_mapping_set, tool_quality_schema_node, usage_map, + agent_ref_map, default_usage_summary, enrich_operation_summary, format_timestamp, + new_prefixed_id, now_string, today_start_utc, tool_quality_mapping_set, + tool_quality_schema_node, usage_map, }, }; impl AdminService { + pub fn operation_state_etag(detail: &OperationDetailView) -> String { + let material = format!( + "operation-state-v2\0{}\0{}\0{}\0{:?}\0{:?}", + detail.workspace_id, + detail.id, + detail.current_draft_version, + detail.status, + detail.latest_published_version + ); + format!("\"{:x}\"", Sha256::digest(material.as_bytes())) + } + + pub fn operation_version_etag(record: &OperationVersionRecord) -> Result { + let bytes = serde_json::to_vec(&record.snapshot) + .map_err(|_| ApiError::internal("operation version serialization failed"))?; + let mut digest = Sha256::new(); + digest.update(b"operation-version-v2\0"); + digest.update(bytes); + Ok(format!("\"{:x}\"", digest.finalize())) + } pub async fn list_operations( &self, workspace_id: &WorkspaceId, @@ -84,6 +107,11 @@ impl AdminService { let refs = agent_ref_map(agent_refs) .remove(operation_id.as_str()) .unwrap_or_default(); + let current_version = self + .registry + .get_operation_version(workspace_id, operation_id, summary.current_draft_version) + .await? + .ok_or_else(|| ApiError::internal("current operation version is unavailable"))?; Ok(OperationDetailView { id: summary.id.as_str().to_owned(), @@ -94,6 +122,11 @@ impl AdminService { protocol: summary.protocol, security_level: summary.security_level, status: summary.status, + availability: if summary.status == OperationStatus::Archived { + OperationAvailability::Archived + } else { + OperationAvailability::Active + }, current_draft_version: summary.current_draft_version, latest_published_version: summary.latest_published_version, created_at: format_timestamp(summary.created_at), @@ -101,11 +134,15 @@ impl AdminService { published_at: summary.published_at.map(format_timestamp), draft_version_ref: VersionRef { version: summary.current_draft_version, - status: summary.status, + status: if current_version.status == OperationStatus::Published { + OperationVersionState::Published + } else { + OperationVersionState::Draft + }, }, published_version_ref: summary.latest_published_version.map(|version| VersionRef { version, - status: OperationStatus::Published, + status: OperationVersionState::Published, }), agent_refs: refs, }) @@ -155,6 +192,8 @@ impl AdminService { } let snapshot = self.new_operation_snapshot(payload)?; + self.validate_registry_operation_in_workspace(workspace_id, &snapshot) + .await?; let operation_id = snapshot.id.clone(); self.registry @@ -240,6 +279,7 @@ impl AdminService { workspace_id: &WorkspaceId, operation_id: &OperationId, payload: NewVersionPayload, + expected_state: &OperationStateExpectation, ) -> Result { self.validate_operation_payload(&payload.operation)?; @@ -254,7 +294,27 @@ impl AdminService { ) })?; let now = OffsetDateTime::now_utc(); - let version = summary.current_draft_version + 1; + if summary.current_draft_version != expected_state.current_draft_version + || summary.status != expected_state.status + || summary.latest_published_version != expected_state.latest_published_version + { + return Err(crank_registry::RegistryError::OperationStaleVersion { + operation_id: operation_id.as_str().to_owned(), + expected: expected_state.current_draft_version, + actual: summary.current_draft_version, + } + .into()); + } + let version = summary + .current_draft_version + .checked_add(1) + .filter(|value| i32::try_from(*value).is_ok()) + .ok_or_else(|| { + ApiError::conflict_with_context( + "operation revision limit reached", + json!({ "error_code": "operation_revision_exhausted" }), + ) + })?; let snapshot = RegistryOperation { id: operation_id.clone(), name: payload.operation.name, @@ -283,13 +343,19 @@ impl AdminService { published_at: None, }; + self.validate_registry_operation_in_workspace(workspace_id, &snapshot) + .await?; + self.registry - .create_version(CreateVersionRequest { - workspace_id, - snapshot: &snapshot, - change_note: payload.change_note.as_deref(), - created_by: None, - }) + .create_version_cas( + CreateVersionRequest { + workspace_id, + snapshot: &snapshot, + change_note: payload.change_note.as_deref(), + created_by: None, + }, + expected_state, + ) .await?; info!( name: "admin.operation.version_created", @@ -313,21 +379,20 @@ impl AdminService { workspace_id: &WorkspaceId, operation_id: &OperationId, payload: UpdateOperationPayload, + expected_state: &OperationStateExpectation, ) -> Result { let existing = self .get_operation_version( workspace_id, operation_id, - self.get_operation(workspace_id, operation_id) - .await? - .current_draft_version, + expected_state.current_draft_version, ) .await?; let updated_at = OffsetDateTime::now_utc(); let snapshot = RegistryOperation { id: operation_id.clone(), - name: existing.snapshot.name, + name: existing.snapshot.name.clone(), display_name: payload.display_name, category: payload.category, protocol: existing.snapshot.protocol, @@ -341,24 +406,39 @@ impl AdminService { output_mapping: payload.output_mapping, execution_config: payload.execution_config, tool_description: payload.tool_description, - samples: existing.snapshot.samples, - generated_draft: existing.snapshot.generated_draft, - config_export: existing.snapshot.config_export, + samples: existing.snapshot.samples.clone(), + generated_draft: existing.snapshot.generated_draft.clone(), + config_export: existing.snapshot.config_export.clone(), wizard_state: payload.wizard_state, created_at: existing.snapshot.created_at, updated_at, published_at: existing.snapshot.published_at, }; - self.validate_registry_operation(&snapshot)?; + self.validate_registry_operation_in_workspace(workspace_id, &snapshot) + .await?; + if existing.snapshot.portable_semantically_eq(&snapshot) + && existing.snapshot.wizard_state == snapshot.wizard_state + { + self.registry + .verify_operation_state(workspace_id, operation_id, expected_state) + .await?; + return Ok(OperationMutationResult { + operation_id: operation_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + version: existing.version, + status: existing.status, + updated_at: format_timestamp(existing.snapshot.updated_at), + }); + } self.registry - .update_operation_draft(workspace_id, &snapshot) + .update_operation_draft_cas(workspace_id, &snapshot, expected_state) .await?; Ok(OperationMutationResult { operation_id: operation_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(), - version: snapshot.version, + version: snapshot.version.saturating_add(1), status: snapshot.status, updated_at: format_timestamp(updated_at), }) @@ -370,16 +450,56 @@ impl AdminService { workspace_id: &WorkspaceId, operation_id: &OperationId, version: u32, + expected_state: &OperationStateExpectation, ) -> Result { + let record = self + .get_operation_version(workspace_id, operation_id, version) + .await?; + self.validate_registry_operation_in_workspace(workspace_id, &record.snapshot) + .await?; + let mut findings = crank_core::analyze_tool_identity_quality( + &record.snapshot.name, + &record.snapshot.tool_description, + ) + .findings; + findings.extend( + crank_core::analyze_tool_schema_quality( + "input_schema", + &tool_quality_schema_node(&record.snapshot.input_schema), + ) + .findings, + ); + findings.extend( + crank_core::analyze_tool_response_projection_quality(&tool_quality_mapping_set( + &record.snapshot.output_mapping, + )) + .findings, + ); + let report = crank_core::ToolQualityReport::new(findings); + if report.blocking { + return Err(ApiError::unprocessable_with_context( + "operation publish is blocked by quality findings", + json!({ + "error_code": "operation_publish_blocked", + "findings": report.findings + }), + )); + } let published_at = OffsetDateTime::now_utc(); self.registry - .publish_operation(PublishRequest { - workspace_id, - operation_id, - version, - published_at: &published_at, - published_by: None, - }) + .publish_operation_cas( + PublishRequest { + workspace_id, + operation_id, + version, + published_at: &published_at, + published_by: None, + }, + expected_state, + ) + .await?; + let authoritative = self + .get_operation_version(workspace_id, operation_id, version) .await?; info!( name: "admin.operation.published", @@ -392,7 +512,11 @@ impl AdminService { operation_id: operation_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(), published_version: version, - published_at: format_timestamp(published_at), + published_at: authoritative + .snapshot + .published_at + .map(format_timestamp) + .unwrap_or_else(|| format_timestamp(published_at)), }) } @@ -401,19 +525,21 @@ impl AdminService { &self, workspace_id: &WorkspaceId, operation_id: &OperationId, + expected_state: &OperationStateExpectation, ) -> Result { let summary = self.get_operation(workspace_id, operation_id).await?; let updated_at = OffsetDateTime::now_utc(); self.registry - .archive_operation(workspace_id, operation_id, &updated_at) + .archive_operation_cas(workspace_id, operation_id, &updated_at, expected_state) .await?; + let authoritative = self.get_operation(workspace_id, operation_id).await?; Ok(OperationMutationResult { operation_id: operation_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(), version: summary.current_draft_version, status: OperationStatus::Archived, - updated_at: format_timestamp(updated_at), + updated_at: authoritative.updated_at, }) } @@ -422,11 +548,12 @@ impl AdminService { &self, workspace_id: &WorkspaceId, operation_id: &OperationId, + expected_state: &OperationStateExpectation, ) -> Result { let summary = self.get_operation(workspace_id, operation_id).await?; let updated_at = now_string()?; self.registry - .delete_operation(workspace_id, operation_id) + .delete_operation_cas(workspace_id, operation_id, expected_state) .await?; Ok(OperationMutationResult { @@ -448,70 +575,77 @@ impl AdminService { ) -> Result { let request_id = correlation.request_id().as_str(); let trace_id = correlation.trace_id().as_str(); - let runtime_request_context = RuntimeRequestContext::from_correlation(correlation) + let mut runtime_request_context = RuntimeRequestContext::from_correlation(correlation) .with_metering_context(workspace_id.clone(), None, InvocationSource::AdminTestRun); + if let Some(token) = payload.confirmation_token.as_deref() { + runtime_request_context = runtime_request_context.with_confirmation_token(token); + } + let locale = payload + .locale + .as_deref() + .filter(|value| { + value.eq_ignore_ascii_case("ru") || value.to_ascii_lowercase().starts_with("ru-") + }) + .map_or(crank_core::ExecutionLocale::En, |_| { + crank_core::ExecutionLocale::Ru + }); let record = self .get_operation_version(workspace_id, operation_id, payload.version) .await?; + let summary = self.get_operation(workspace_id, operation_id).await?; + if summary.status == OperationStatus::Archived + || record.status != OperationStatus::Draft + || summary.current_draft_version != payload.version + { + return Err(ApiError::conflict_with_context( + "only the current saved Draft can be tested", + json!({ + "operation_id": operation_id.as_str(), + "current_version": summary.current_draft_version, + "tested_version": payload.version, + "error_code": "operation_invalid_transition" + }), + )); + } let runtime = RuntimeOperation::from(record.snapshot.clone()); let mode = ExecutionMode::Unary; - let preview_span = crank_trace::Stage::RuntimeArgumentsMap.span(); - let preview_result = preview_span - .in_scope(|| build_request_preview(&record.snapshot.input_mapping, &payload.input)); - let request_preview = match preview_result { - Ok(preview) => preview, - Err(error) => { - crank_trace::StageOutcome::Error.record(&preview_span); - crank_trace::ErrorCategory::Mapping.record(&preview_span); - drop(preview_span); - self.record_invocation(InvocationRecordRequest { - workspace_id, - agent_id: None, - operation: &record.snapshot, - request_id: Some(request_id), - trace_id: Some(trace_id), - source: InvocationSource::AdminTestRun, - level: InvocationLevel::Error, - status: InvocationStatus::Error, - message: "mapping preview failed".to_owned(), - status_code: None, - error_kind: Some("mapping".to_owned()), - duration_ms: 0, - request_preview: Value::Null, - response_preview: Value::Null, - }) - .await; - return Ok(TestRunResult { - ok: false, - mode, - request_preview: Value::Null, - response_preview: Value::Null, - errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping( - error, - ))], - }); - } - }; - crank_trace::StageOutcome::Success.record(&preview_span); - drop(preview_span); - let resolved_auth = self - .resolve_operation_auth(workspace_id, &runtime.execution_config) + .resolve_operation_auth(workspace_id, &runtime.execution_config, None) .await; let started_at = std::time::Instant::now(); - match match resolved_auth { + let correlation = crank_core::CorrelationContext::new( + runtime_request_context.request_id.clone(), + runtime_request_context.trace_context.clone(), + ); + let execution_result = match resolved_auth { Ok(resolved_auth) => { - self.runtime - .execute_request( - RuntimeExecutionRequest::new(&runtime, &payload.input) - .with_optional_auth(resolved_auth.as_ref()) - .with_context(&runtime_request_context), - ) - .await + match RuntimeExecutionRequest::try_new( + workspace_id, + ExecutionOrigin::AdminDraft, + None, + &runtime, + &payload.input, + ExecutionAuthorization::Authorized, + resolved_auth.as_ref(), + &runtime_request_context, + std::time::Instant::now() + + std::time::Duration::from_millis( + runtime.execution_config.timeout_ms.max(1), + ), + ) { + Ok(request) => self.runtime.execute_outcome(request).await, + Err(_) => Err(crank_core::ExecutionFailure::new( + crank_core::ExecutionErrorCode::RuntimeInternal, + correlation.clone(), + )), + } } - Err(error) => Err(error), - } { - Ok(response_preview) => { + Err(error) => Err(crank_runtime::normalize_runtime_error(&error, &correlation)), + }; + match execution_result { + Ok(success) => { + let response_preview = crank_core::sanitize_invocation_preview(&success.output); + let request_preview = success.request_preview; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); self.record_invocation(InvocationRecordRequest { @@ -526,6 +660,10 @@ impl AdminService { message: "admin test run completed".to_owned(), status_code: None, error_kind: None, + execution_stage: Some(crank_core::ExecutionStage::Runtime), + execution_error_code: None, + retryability: Some(crank_core::Retryability::Never), + outcome_certainty: Some(crank_core::OutcomeCertainty::Certain), duration_ms, request_preview: request_preview.clone(), response_preview: response_preview.clone(), @@ -534,12 +672,15 @@ impl AdminService { Ok(TestRunResult { ok: true, mode, + tested_version: payload.version, + request_id: request_id.to_owned(), + trace_id: trace_id.to_owned(), request_preview, response_preview, errors: Vec::new(), }) } - Err(error) => { + Err(failure) => { let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); self.record_invocation(InvocationRecordRequest { @@ -551,20 +692,29 @@ impl AdminService { source: InvocationSource::AdminTestRun, level: InvocationLevel::Error, status: InvocationStatus::Error, - message: error.to_string(), - status_code: None, - error_kind: Some(runtime_error_code(&error).to_owned()), + message: "runtime execution failed".to_owned(), + status_code: failure.upstream_status(), + error_kind: Some(failure.error_code().as_str().to_owned()), + execution_stage: Some(failure.stage()), + execution_error_code: Some(failure.error_code()), + retryability: Some(failure.retryability()), + outcome_certainty: Some(failure.outcome_certainty()), duration_ms, - request_preview: request_preview.clone(), + request_preview: Value::Null, response_preview: Value::Null, }) .await; Ok(TestRunResult { ok: false, mode, - request_preview, + tested_version: payload.version, + request_id: request_id.to_owned(), + trace_id: trace_id.to_owned(), + request_preview: Value::Null, response_preview: Value::Null, - errors: vec![crate::error::runtime_test_failure(&error)], + errors: vec![crate::error::execution_test_failure_localized( + &failure, locale, + )], }) } } diff --git a/apps/admin-api/src/service/secrets.rs b/apps/admin-api/src/service/secrets.rs index 33079ec..3c32cec 100644 --- a/apps/admin-api/src/service/secrets.rs +++ b/apps/admin-api/src/service/secrets.rs @@ -12,7 +12,8 @@ use tracing::{info, instrument}; use crate::{ error::ApiError, service::{ - AdminService, AuthProfilePayload, RotateSecretPayload, SecretPayload, new_prefixed_id, + AdminAuditContext, AdminService, AuthProfilePayload, CredentialAuditRecord, + RotateSecretPayload, SecretPayload, new_prefixed_id, }, }; @@ -57,15 +58,46 @@ impl AdminService { }) } - #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))] + #[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))] pub async fn create_secret( &self, workspace_id: &WorkspaceId, created_by: Option<&UserId>, payload: SecretPayload, + audit_context: Option<&AdminAuditContext>, ) -> Result { - self.ensure_workspace_exists(workspace_id).await?; - validate_secret_payload(&payload)?; + if let Err(error) = self.ensure_workspace_exists(workspace_id).await { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.create_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: "pending", + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + if let Err(error) = validate_secret_payload(&payload) { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.create_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: "pending", + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } let now = OffsetDateTime::now_utc(); let secret = Secret { @@ -82,89 +114,322 @@ impl AdminService { let ciphertext = self .secret_crypto .encrypt(&payload.value) - .map_err(|error| ApiError::internal(error.to_string()))?; + .map_err(|error| ApiError::internal(error.to_string())); + let ciphertext = match ciphertext { + Ok(ciphertext) => ciphertext, + Err(error) => { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.create_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret.id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + }; - self.registry + if let Err(error) = self + .registry .create_secret(CreateSecretRequest { secret: &secret, ciphertext: &ciphertext, key_version: self.secret_crypto.key_version(), + master_key_epoch: self.secret_crypto.master_key_epoch(), created_by, }) - .await?; + .await + { + let error = ApiError::from(error); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.create_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret.id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } info!( name: "admin.secret.created", secret_id = %secret.id.as_str(), "secret created" ); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.created", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret.id.as_str(), + credential_type: "secret", + outcome: "success", + reason: "credential_created", + }, + ) + .await; Ok(secret) } - #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] + #[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] pub async fn rotate_secret( &self, workspace_id: &WorkspaceId, secret_id: &SecretId, created_by: Option<&UserId>, payload: RotateSecretPayload, + audit_context: Option<&AdminAuditContext>, ) -> Result { - self.ensure_workspace_exists(workspace_id).await?; - if payload.value.is_null() { - return Err(ApiError::validation("secret value must not be null")); + if let Err(error) = self.ensure_workspace_exists(workspace_id).await { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.rotate_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + let existing = match self.get_secret(workspace_id, secret_id).await { + Ok(secret) => secret, + Err(error) => { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.rotate_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + }; + if let Err(error) = validate_secret_value(existing.kind, &payload.value) { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.rotate_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); } let now = OffsetDateTime::now_utc(); let ciphertext = self .secret_crypto .encrypt(&payload.value) - .map_err(|error| ApiError::internal(error.to_string()))?; - self.registry + .map_err(|error| ApiError::internal(error.to_string())); + let ciphertext = match ciphertext { + Ok(ciphertext) => ciphertext, + Err(error) => { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.rotate_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + }; + let version = match self + .registry .rotate_secret(RotateSecretRequest { workspace_id, secret_id, ciphertext: &ciphertext, key_version: self.secret_crypto.key_version(), + master_key_epoch: self.secret_crypto.master_key_epoch(), created_at: &now, updated_at: &now, created_by, }) - .await?; + .await + { + Ok(version) => version, + Err(error) => { + let error = ApiError::from(error); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.rotate_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + }; info!( name: "admin.secret.rotated", secret_id = %secret_id.as_str(), "secret rotated" ); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.rotated", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "success", + reason: "credential_rotated", + }, + ) + .await; - self.get_secret(workspace_id, secret_id).await + Ok(Secret { + current_version: version.secret_version.version, + updated_at: now, + ..existing + }) } - #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] + #[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] pub async fn delete_secret( &self, workspace_id: &WorkspaceId, secret_id: &SecretId, + audit_context: Option<&AdminAuditContext>, ) -> Result<(), ApiError> { - self.ensure_workspace_exists(workspace_id).await?; - if let Some(profile) = self + if let Err(error) = self.ensure_workspace_exists(workspace_id).await { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.delete_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + let referencing_profiles = match self .registry .list_auth_profiles_referencing_secret(workspace_id, secret_id) - .await? - .into_iter() - .next() + .await { + Ok(profiles) => profiles, + Err(error) => { + let error = ApiError::from(error); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.delete_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + }; + if let Some(profile) = referencing_profiles.into_iter().next() { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.delete_denied", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "denied", + reason: "secret_referenced_by_auth_profile", + }, + ) + .await; return Err(RegistryError::SecretReferencedByAuthProfile { secret_id: secret_id.as_str().to_owned(), auth_profile_id: profile.id.as_str().to_owned(), } .into()); } - self.registry.delete_secret(workspace_id, secret_id).await?; + if let Err(error) = self.registry.delete_secret(workspace_id, secret_id).await { + let error = ApiError::from(error); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.delete_failed", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } info!( name: "admin.secret.deleted", secret_id = %secret_id.as_str(), "secret deleted" ); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.secret.deleted", + target_kind: crank_core::AuditTargetKind::Secret, + workspace_id, + target_id: secret_id.as_str(), + credential_type: "secret", + outcome: "success", + reason: "credential_deleted", + }, + ) + .await; Ok(()) } @@ -185,39 +450,118 @@ impl AdminService { }) } - #[instrument(skip(self, payload), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))] + #[instrument(skip(self, payload, audit_context), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))] pub async fn create_auth_profile( &self, workspace_id: &WorkspaceId, payload: AuthProfilePayload, + audit_context: Option<&AdminAuditContext>, ) -> Result { - validate_auth_profile_kind(payload.kind, &payload.config)?; - self.ensure_workspace_exists(workspace_id).await?; - self.validate_auth_profile_secret_ids(workspace_id, &payload.config) - .await?; + if let Err(error) = validate_auth_profile_payload(&payload) { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.auth_profile.create_failed", + target_kind: crank_core::AuditTargetKind::AuthProfile, + workspace_id, + target_id: "pending", + credential_type: "auth_profile", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + if let Err(error) = self.ensure_workspace_exists(workspace_id).await { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.auth_profile.create_failed", + target_kind: crank_core::AuditTargetKind::AuthProfile, + workspace_id, + target_id: "pending", + credential_type: "auth_profile", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } + if let Err(error) = self + .validate_auth_profile_secret_ids(workspace_id, &payload.config) + .await + { + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.auth_profile.create_failed", + target_kind: crank_core::AuditTargetKind::AuthProfile, + workspace_id, + target_id: "pending", + credential_type: "auth_profile", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } let now = OffsetDateTime::now_utc(); let profile = AuthProfile { id: AuthProfileId::new(new_prefixed_id("auth")), workspace_id: workspace_id.clone(), - name: payload.name, + name: payload.name.trim().to_owned(), kind: payload.kind, config: payload.config, created_at: now, updated_at: now, }; - self.registry + if let Err(error) = self + .registry .save_auth_profile(SaveAuthProfileRequest { workspace_id, profile: &profile, }) - .await?; + .await + { + let error = ApiError::from(error); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.auth_profile.create_failed", + target_kind: crank_core::AuditTargetKind::AuthProfile, + workspace_id, + target_id: profile.id.as_str(), + credential_type: "auth_profile", + outcome: "failure", + reason: error.code(), + }, + ) + .await; + return Err(error); + } info!( name: "admin.auth_profile.created", auth_profile_id = %profile.id.as_str(), "auth profile created" ); + self.record_credential_audit( + audit_context, + CredentialAuditRecord { + action: "credential.auth_profile.created", + target_kind: crank_core::AuditTargetKind::AuthProfile, + workspace_id, + target_id: profile.id.as_str(), + credential_type: "auth_profile", + outcome: "success", + reason: "credential_created", + }, + ) + .await; Ok(profile) } @@ -228,13 +572,45 @@ impl AdminService { config: &AuthConfig, ) -> Result<(), ApiError> { for secret_id in config.secret_ids() { - self.get_secret(workspace_id, secret_id).await?; + let secret = self.get_secret(workspace_id, secret_id).await?; + if secret.status != SecretStatus::Active { + return Err(RegistryError::SecretInactive { + secret_id: secret_id.as_str().to_owned(), + } + .into()); + } } Ok(()) } } +fn validate_auth_profile_payload(payload: &AuthProfilePayload) -> Result<(), ApiError> { + const MAX_PROFILE_NAME_CHARS: usize = 128; + + let profile_name = payload.name.trim(); + if profile_name.is_empty() || profile_name.chars().count() > MAX_PROFILE_NAME_CHARS { + return Err(ApiError::validation( + "auth profile name must contain 1 to 128 characters", + )); + } + if profile_name.chars().any(char::is_control) { + return Err(ApiError::validation( + "auth profile name contains unsupported characters", + )); + } + + validate_auth_profile_kind(payload.kind, &payload.config)?; + match &payload.config { + AuthConfig::Bearer(config) => validate_auth_header_name(&config.header_name)?, + AuthConfig::Basic(_) => {} + AuthConfig::ApiKeyHeader(config) => validate_auth_header_name(&config.header_name)?, + AuthConfig::ApiKeyQuery(config) => validate_auth_query_name(&config.param_name)?, + } + + Ok(()) +} + fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> { let is_match = matches!( (kind, config), @@ -252,13 +628,145 @@ fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), } fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> { - if payload.name.trim().is_empty() { - return Err(ApiError::validation("secret name must not be empty")); + const MAX_SECRET_NAME_CHARS: usize = 128; + + let name = payload.name.trim(); + if name.is_empty() || name.chars().count() > MAX_SECRET_NAME_CHARS { + return Err(ApiError::validation( + "secret name must contain 1 to 128 characters", + )); + } + if name.chars().any(char::is_control) { + return Err(ApiError::validation( + "secret name contains unsupported characters", + )); } - if payload.value.is_null() { + validate_secret_value(payload.kind, &payload.value) +} + +fn validate_secret_value( + kind: crank_core::SecretKind, + value: &serde_json::Value, +) -> Result<(), ApiError> { + const MAX_SECRET_VALUE_BYTES: usize = 65_536; + const MAX_SECRET_STRING_BYTES: usize = 16_384; + + if value.is_null() { return Err(ApiError::validation("secret value must not be null")); } + let encoded = serde_json::to_vec(value) + .map_err(|_| ApiError::validation("secret value must be valid JSON"))?; + if encoded.len() > MAX_SECRET_VALUE_BYTES { + return Err(ApiError::validation("secret value is too large")); + } + + match kind { + crank_core::SecretKind::Token | crank_core::SecretKind::Header => { + if value.is_string() { + validate_non_empty_secret_string(value, "secret value", MAX_SECRET_STRING_BYTES)?; + } else { + let object = value.as_object().ok_or_else(|| { + ApiError::validation( + "token/header secret must be a string or a single token/value field", + ) + })?; + let field = object + .get("token") + .or_else(|| object.get("value")) + .filter(|_| object.len() == 1) + .ok_or_else(|| { + ApiError::validation("token/header secret must contain only token or value") + })?; + validate_non_empty_secret_string(field, "secret value", MAX_SECRET_STRING_BYTES)?; + } + } + crank_core::SecretKind::UsernamePassword => { + let object = value.as_object().ok_or_else(|| { + ApiError::validation("username_password secret must contain username and password") + })?; + if object.len() != 2 + || !object.contains_key("username") + || !object.contains_key("password") + { + return Err(ApiError::validation( + "username_password secret must contain only username and password", + )); + } + validate_non_empty_secret_string( + &object["username"], + "secret username", + MAX_SECRET_STRING_BYTES, + )?; + validate_non_empty_secret_string( + &object["password"], + "secret password", + MAX_SECRET_STRING_BYTES, + )?; + } + crank_core::SecretKind::Generic => {} + } + + Ok(()) +} + +fn validate_non_empty_secret_string( + value: &serde_json::Value, + field: &str, + max_bytes: usize, +) -> Result<(), ApiError> { + let value = value + .as_str() + .ok_or_else(|| ApiError::validation(format!("{field} must be a string")))?; + if value.is_empty() { + return Err(ApiError::validation(format!("{field} must not be empty"))); + } + if value.len() > max_bytes { + return Err(ApiError::validation(format!("{field} is too large"))); + } + Ok(()) +} + +fn validate_auth_header_name(value: &str) -> Result<(), ApiError> { + const MAX_HEADER_NAME_BYTES: usize = 128; + if value.is_empty() + || value.len() > MAX_HEADER_NAME_BYTES + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) + { + return Err(ApiError::validation("auth header name is invalid")); + } + Ok(()) +} + +fn validate_auth_query_name(value: &str) -> Result<(), ApiError> { + const MAX_QUERY_NAME_BYTES: usize = 128; + if value.is_empty() + || value.len() > MAX_QUERY_NAME_BYTES + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'[' | b']') + }) + { + return Err(ApiError::validation("auth query parameter name is invalid")); + } Ok(()) } diff --git a/apps/admin-api/src/service/workspaces.rs b/apps/admin-api/src/service/workspaces.rs index cbf8e7f..1d5904b 100644 --- a/apps/admin-api/src/service/workspaces.rs +++ b/apps/admin-api/src/service/workspaces.rs @@ -97,6 +97,7 @@ impl AdminService { user, memberships, current_workspace_id: Some(workspace_id.as_str().to_owned()), + csrf_token: self.rotate_session_csrf_token(session_id).await?, }) } diff --git a/apps/admin-api/src/state.rs b/apps/admin-api/src/state.rs index 54b7293..d0ff2fa 100644 --- a/apps/admin-api/src/state.rs +++ b/apps/admin-api/src/state.rs @@ -1,14 +1,14 @@ use crate::service::AdminService; use crank_runtime::RequestRateLimiter; +use std::net::IpAddr; #[derive(Clone)] pub struct AppState { pub service: AdminService, pub api_rate_limiter: RequestRateLimiter, - /// Whether to trust `X-Real-IP` / `X-Forwarded-For` for client identification. + /// Immediate peer IPs allowed to supply `X-Real-IP` / `X-Forwarded-For`. /// - /// Only enable when the service sits behind a trusted reverse proxy that - /// overwrites these headers (e.g. the bundled nginx). When disabled the - /// real TCP peer address is used, which a client cannot spoof. - pub trust_forwarded_headers: bool, + /// Only configure reverse proxies that overwrite these headers. When the + /// peer is absent or not listed the real TCP peer address is used. + pub trusted_proxy_ips: Vec, } diff --git a/apps/admin-api/tests/config_startup.rs b/apps/admin-api/tests/config_startup.rs index a248c26..91262cb 100644 --- a/apps/admin-api/tests/config_startup.rs +++ b/apps/admin-api/tests/config_startup.rs @@ -1,12 +1,20 @@ use std::process::Command; +use crank_registry::{MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry}; +use crank_runtime::SecretCrypto; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000"; +const REGISTERED_MASTER_KEY: &str = "registered-master-key-CANARY_SECRET_VALUE-00000000"; +const WRONG_MASTER_KEY: &str = "wrong-master-key-CANARY_SECRET_VALUE-0000000000000"; + fn run_with(entries: &[(&str, &str)]) -> String { let mut command = Command::new(env!("CARGO_BIN_EXE_admin-api")); for field in crank_config::field_registry() { command.env_remove(field.env_name); } command.envs([ - ("CRANK_MASTER_KEY", "master"), + ("CRANK_MASTER_KEY", TEST_MASTER_KEY), ("CRANK_SESSION_SECRET", "session"), ("CRANK_PASSWORD_PEPPER", "pepper"), ("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"), @@ -69,3 +77,92 @@ async fn fresh_database_startup_is_read_only() { .unwrap(); assert!(!present); } + +#[tokio::test] +async fn master_key_mismatch_blocks_startup_with_safe_diagnostic() { + let database_url = crank_test_support::postgres_schema_url("admin_master_key_mismatch").await; + let applied = Command::new(env!("CARGO_BIN_EXE_crank-migrate")) + .arg("apply") + .env("CRANK_DATABASE_URL", &database_url) + .output() + .expect("migration command executes"); + assert!( + applied.status.success(), + "{}", + String::from_utf8_lossy(&applied.stderr) + ); + let registry = PostgresRegistry::connect(&database_url).await.unwrap(); + let crypto = SecretCrypto::new(REGISTERED_MASTER_KEY).unwrap(); + registry + .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { + epoch: 1, + fingerprint: crypto.master_key_fingerprint(), + cipher_contract: MASTER_KEY_CIPHER_CONTRACT, + observed_at: &OffsetDateTime::parse("2026-08-21T00:00:00Z", &Rfc3339).unwrap(), + }) + .await + .unwrap(); + + let stderr = run_with(&[ + ("CRANK_DATABASE_URL", &database_url), + ("CRANK_MASTER_KEY", WRONG_MASTER_KEY), + ]); + assert!(stderr.contains("master_key_identity_mismatch"), "{stderr}"); + assert!(!stderr.contains("registered-master-key")); + assert!(!stderr.contains("wrong-master-key")); +} + +#[tokio::test] +async fn populated_database_without_identity_rejects_wrong_first_key() { + let database_url = + crank_test_support::postgres_schema_url("admin_master_key_first_registration_wrong").await; + let applied = Command::new(env!("CARGO_BIN_EXE_crank-migrate")) + .arg("apply") + .env("CRANK_DATABASE_URL", &database_url) + .output() + .expect("migration command executes"); + assert!( + applied.status.success(), + "{}", + String::from_utf8_lossy(&applied.stderr) + ); + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + let old_crypto = SecretCrypto::new(REGISTERED_MASTER_KEY).unwrap(); + let ciphertext = old_crypto + .encrypt(&serde_json::json!({"token": "CANARY_SECRET_VALUE"})) + .unwrap(); + sqlx::query( + "insert into secrets ( + id, workspace_id, name, kind, status, current_version, created_at, updated_at + ) values ( + 'legacy_secret', 'ws_default', 'legacy secret', 'token', 'active', 1, now(), now() + )", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "insert into secret_versions ( + secret_id, version, ciphertext, key_version, master_key_epoch, created_at, created_by + ) values ( + 'legacy_secret', 1, $1, $2, 1, now(), null + )", + ) + .bind(ciphertext) + .bind(old_crypto.key_version()) + .execute(&pool) + .await + .unwrap(); + + let stderr = run_with(&[ + ("CRANK_DATABASE_URL", &database_url), + ("CRANK_MASTER_KEY", WRONG_MASTER_KEY), + ]); + assert!(stderr.contains("startup_failed"), "{stderr}"); + assert!(!stderr.contains("CANARY_SECRET_VALUE")); + let identities: i64 = sqlx::query_scalar("select count(*) from master_key_identities") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(identities, 0); +} diff --git a/apps/admin-api/tests/integration.rs b/apps/admin-api/tests/integration.rs index bbdb103..8c9936a 100644 --- a/apps/admin-api/tests/integration.rs +++ b/apps/admin-api/tests/integration.rs @@ -1,8 +1,13 @@ mod integration { + mod agent_catalog; mod auth_rate_limit; mod common; mod community_access_usage; + mod credential_lifecycle; + mod logs_usage; + mod onboarding; mod openapi_import; + mod operation_lifecycle; mod operations_agents; mod request_context; mod secrets_import_auth; diff --git a/apps/admin-api/tests/integration/agent_catalog.rs b/apps/admin-api/tests/integration/agent_catalog.rs new file mode 100644 index 0000000..b1b8d3d --- /dev/null +++ b/apps/admin-api/tests/integration/agent_catalog.rs @@ -0,0 +1,310 @@ +#![allow(dead_code, unused_imports)] + +use super::common::*; + +use serde_json::{Value, json}; +use serial_test::serial; + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn binding_requires_exact_published_operation_version() { + let registry = test_registry().await; + let storage_root = test_storage_root("agent_catalog_binding_requires_published"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let draft_operation = assert_success_json( + client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "draft_not_bindable_tool", + )) + .send() + .await + .unwrap(), + ) + .await; + let draft_operation_id = draft_operation["operation_id"].as_str().unwrap().to_owned(); + + let agent = assert_success_json( + client + .post(format!("{base_url}/agents")) + .json(&json!({ + "slug": "strict-catalog-agent", + "display_name": "Strict Catalog Agent", + "description": "Rejects draft bindings", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let agent_id = agent["agent_id"].as_str().unwrap().to_owned(); + + let response = client + .post(format!("{base_url}/agents/{agent_id}/bindings")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) + .json(&json!([ + { + "operation_id": draft_operation_id, + "operation_version": 1, + "tool_name": "draft_not_bindable_tool", + "tool_title": "Draft should not bind", + "tool_description_override": null, + "enabled": true + } + ])) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + let body = response.json::().await.unwrap(); + assert_eq!( + body["error"]["code"], "agent_binding_not_published", + "binding Draft Operation must fail before publish and not be silently filtered" + ); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn stale_agent_revision_rejects_mutation() { + let registry = test_registry().await; + let storage_root = test_storage_root("agent_catalog_stale_revision"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let operation = assert_success_json( + client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "stale_agent_tool", + )) + .send() + .await + .unwrap(), + ) + .await; + let operation_id = operation["operation_id"].as_str().unwrap().to_owned(); + assert_success_json( + client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &operation_id).await, + ) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(), + ) + .await; + + let agent = assert_success_json( + client + .post(format!("{base_url}/agents")) + .json(&json!({ + "slug": "stale-agent", + "display_name": "Stale Agent", + "description": "Stale revision test", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let agent_id = agent["agent_id"].as_str().unwrap().to_owned(); + assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/bindings")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) + .json(&json!([{ + "operation_id": operation_id, + "operation_version": 1, + "tool_name": "stale_agent_tool", + "tool_title": "Stale Agent Tool", + "enabled": true + }])) + .send() + .await + .unwrap(), + ) + .await; + + assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/publish")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(), + ) + .await; + + let response = client + .post(format!("{base_url}/agents/{agent_id}/bindings")) + .json(&json!([])) + .send() + .await + .unwrap(); + + assert_eq!( + response.status(), + reqwest::StatusCode::PRECONDITION_REQUIRED, + "Agent mutations after read must require current revision precondition" + ); + let body = response.json::().await.unwrap(); + assert_eq!(body["error"]["code"], "agent_precondition_required"); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn unpublishes_and_archives_agent() { + let registry = test_registry().await; + let storage_root = test_storage_root("agent_statuses"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let operation = assert_success_json( + client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_create_lead_agent_status", + )) + .send() + .await + .unwrap(), + ) + .await; + let operation_id = operation["operation_id"].as_str().unwrap().to_owned(); + + client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &operation_id).await, + ) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(); + + let created = assert_success_json( + client + .post(format!("{base_url}/agents")) + .json(&json!({ + "slug": "sales-routing", + "display_name": "Sales Routing", + "description": "Routing agent", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let agent_id = created["agent_id"].as_str().unwrap().to_owned(); + + client + .post(format!("{base_url}/agents/{agent_id}/bindings")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) + .json(&json!([ + { + "operation_id": operation_id, + "operation_version": 1, + "tool_name": "crm_create_lead_agent_status", + "tool_title": "Create Lead", + "tool_description_override": null, + "enabled": true + } + ])) + .send() + .await + .unwrap(); + + client + .post(format!("{base_url}/agents/{agent_id}/publish")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(); + + let unpublished = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/unpublish")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) + .json(&json!({})) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(unpublished["agent_id"], agent_id); + + let draft_detail = assert_success_json( + client + .get(format!("{base_url}/agents/{agent_id}")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(draft_detail["status"], "draft"); + assert_eq!(draft_detail["latest_published_version"], 1); + + let archived = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/archive")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) + .json(&json!({})) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(archived["agent_id"], agent_id); + + let archived_detail = assert_success_json( + client + .get(format!("{base_url}/agents/{agent_id}")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(archived_detail["status"], "archived"); +} diff --git a/apps/admin-api/tests/integration/auth_rate_limit.rs b/apps/admin-api/tests/integration/auth_rate_limit.rs index f4d3b39..05cd218 100644 --- a/apps/admin-api/tests/integration/auth_rate_limit.rs +++ b/apps/admin-api/tests/integration/auth_rate_limit.rs @@ -11,17 +11,22 @@ use std::{ use async_trait::async_trait; use axum::{Json, Router, routing::post}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, }; use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; use crank_mapping::{MappingRule, MappingSet}; -use crank_registry::PostgresRegistry; +use crank_registry::{ + CreateAdminBootstrapContractRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, + MigrationAuthority, PostgresRegistry, +}; use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use serial_test::serial; +use sha2::{Digest, Sha256}; use tokio::net::TcpListener; use admin_api::{ @@ -36,7 +41,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local"; const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_SESSION_SECRET: &str = "test-session-secret"; -const TEST_MASTER_KEY: &str = "test-master-key"; +const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000"; struct TestServer { base_url: String, @@ -92,6 +97,48 @@ impl IdentityProvider for RejectingIdentityProvider { } } +async fn empty_registry(name: &str) -> PostgresRegistry { + let database_url = crank_test_support::postgres_schema_url(name).await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + let registry = PostgresRegistry::connect(&database_url).await.unwrap(); + let secret_crypto = test_secret_crypto(); + registry + .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { + epoch: secret_crypto.master_key_epoch(), + fingerprint: secret_crypto.master_key_fingerprint(), + cipher_contract: MASTER_KEY_CIPHER_CONTRACT, + observed_at: &time::OffsetDateTime::now_utc(), + }) + .await + .unwrap(); + registry +} + +fn bootstrap_token_hash(token: &str) -> String { + let digest = Sha256::digest(format!("bootstrap:{token}").as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +async fn login_client_without_csrf(root_url: &str) -> reqwest::Client { + let client = reqwest::Client::builder() + .cookie_store(true) + .build() + .unwrap(); + client + .post(format!("{root_url}/api/auth/login")) + .json(&json!({ + "email": TEST_AUTH_EMAIL, + "password": TEST_AUTH_PASSWORD, + })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); + client +} + #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_rapid_login_requests_with_429() { @@ -109,7 +156,7 @@ async fn rejects_rapid_login_requests_with_429() { api_rate_limiter: crank_runtime::RequestRateLimiter::new( crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(), ), - trust_forwarded_headers: false, + trusted_proxy_ips: Vec::new(), }); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let handle = tokio::spawn(async move { @@ -195,3 +242,119 @@ async fn login_uses_identity_provider_when_configured() { }; assert_eq!(error.to_string(), "invalid email or password"); } + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn bootstrap_token_creates_first_admin_once_and_never_replays() { + let registry = empty_registry("test_admin_api_bootstrap_token").await; + let token = "bootstrap-token-story-1-12-local-operator-only"; + registry + .create_admin_bootstrap_contract(CreateAdminBootstrapContractRequest { + id: "boot_test_story_1_12", + token_hash: &bootstrap_token_hash(token), + email: "first-owner@crank.local", + display_name: "First Owner", + expires_at: &(time::OffsetDateTime::now_utc() + time::Duration::minutes(15)), + }) + .await + .unwrap(); + + let storage_root = test_storage_root("bootstrap_token"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let root_url = base_url + .as_ref() + .split("/api/admin/workspaces/") + .next() + .unwrap() + .to_owned(); + let client = reqwest::Client::builder() + .cookie_store(true) + .build() + .unwrap(); + + let status = assert_success_json( + client + .get(format!("{root_url}/api/auth/bootstrap/status")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(status["bootstrap_required"], true); + + let completed = assert_success_json( + client + .post(format!("{root_url}/api/auth/bootstrap/complete")) + .json(&json!({ + "token": token, + "password": "first-owner-password-123" + })) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(completed["user"]["email"], "first-owner@crank.local"); + assert!(completed["csrf_token"].as_str().unwrap().len() >= 32); + + let replay = client + .post(format!("{root_url}/api/auth/bootstrap/complete")) + .json(&json!({ + "token": token, + "password": "second-password-should-not-work" + })) + .send() + .await + .unwrap(); + assert_eq!(replay.status(), reqwest::StatusCode::UNAUTHORIZED); + + let status_after = assert_success_json( + client + .get(format!("{root_url}/api/auth/bootstrap/status")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(status_after["bootstrap_required"], false); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn browser_mutations_require_csrf_and_reject_cross_origin_requests() { + let registry = test_registry().await; + let storage_root = test_storage_root("csrf_and_origin"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let root_url = base_url + .as_ref() + .split("/api/admin/workspaces/") + .next() + .unwrap() + .to_owned(); + let client = login_client_without_csrf(&root_url).await; + + let missing_csrf = client + .patch(format!("{root_url}/api/auth/profile")) + .json(&json!({ + "display_name": "Blocked Without CSRF", + "email": TEST_AUTH_EMAIL + })) + .send() + .await + .unwrap(); + assert_eq!(missing_csrf.status(), reqwest::StatusCode::FORBIDDEN); + let missing_body = missing_csrf.text().await.unwrap(); + assert!(!missing_body.contains(TEST_SESSION_SECRET)); + assert!(!missing_body.contains(TEST_AUTH_PASSWORD)); + + let cross_origin = client + .get(format!("{root_url}/api/auth/profile")) + .header("origin", "https://attacker.example") + .send() + .await + .unwrap(); + assert_eq!(cross_origin.status(), reqwest::StatusCode::FORBIDDEN); + let cross_origin_body = cross_origin.text().await.unwrap(); + assert!(!cross_origin_body.contains(TEST_SESSION_SECRET)); + assert!(!cross_origin_body.contains(TEST_AUTH_PASSWORD)); +} diff --git a/apps/admin-api/tests/integration/common.rs b/apps/admin-api/tests/integration/common.rs index e0a8f3b..3e3f32f 100644 --- a/apps/admin-api/tests/integration/common.rs +++ b/apps/admin-api/tests/integration/common.rs @@ -10,12 +10,12 @@ use std::{ use async_trait::async_trait; use axum::{Json, Router, routing::post}; use crank_core::{ - ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, + AuditSink, ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, }; use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; use crank_mapping::{MappingRule, MappingSet}; -use crank_registry::PostgresRegistry; +use crank_registry::{MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry}; use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; @@ -34,7 +34,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local"; const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_SESSION_SECRET: &str = "test-session-secret"; -const TEST_MASTER_KEY: &str = "test-master-key"; +const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000"; pub(super) struct TestServer { base_url: String, @@ -104,7 +104,32 @@ pub(super) fn build_test_app( api_rate_limiter: crank_runtime::RequestRateLimiter::new( crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(), ), - trust_forwarded_headers: false, + trusted_proxy_ips: Vec::new(), + }) +} + +pub(super) fn build_test_app_with_audit_sink( + registry: PostgresRegistry, + storage_root: std::path::PathBuf, + audit_sink: Arc, +) -> Router { + let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]); + let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build(); + build_app(AppState { + service: AdminServiceBuilder::new( + registry, + storage_root, + test_auth_settings(), + test_secret_crypto(), + runtime, + ) + .with_outbound_http_policy(outbound_policy) + .with_audit_sink(audit_sink) + .build(), + api_rate_limiter: crank_runtime::RequestRateLimiter::new( + crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(), + ), + trusted_proxy_ips: Vec::new(), }) } @@ -169,12 +194,13 @@ pub(super) async fn authorized_client(workspace_base_url: impl AsRef) -> re .split("/api/admin/workspaces/") .next() .unwrap(); - let client = reqwest::Client::builder() - .cookie_store(true) + let cookie_jar = Arc::new(reqwest::cookie::Jar::default()); + let login_client = reqwest::Client::builder() + .cookie_provider(cookie_jar.clone()) .build() .unwrap(); - client + let login = login_client .post(format!("{root_url}/api/auth/login")) .json(&json!({ "email": TEST_AUTH_EMAIL, @@ -184,9 +210,67 @@ pub(super) async fn authorized_client(workspace_base_url: impl AsRef) -> re .await .unwrap() .error_for_status() + .unwrap() + .json::() + .await .unwrap(); + let csrf_token = login["csrf_token"] + .as_str() + .expect("login response must include csrf_token"); + let mut default_headers = reqwest::header::HeaderMap::new(); + default_headers.insert( + "x-csrf-token", + reqwest::header::HeaderValue::from_str(csrf_token).unwrap(), + ); - client + reqwest::Client::builder() + .cookie_provider(cookie_jar) + .default_headers(default_headers) + .build() + .unwrap() +} + +pub(super) async fn operation_etag( + client: &reqwest::Client, + workspace_base_url: impl AsRef, + operation_id: &str, +) -> String { + let response = client + .get(format!( + "{}/operations/{operation_id}", + workspace_base_url.as_ref() + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + response + .headers() + .get(reqwest::header::ETAG) + .expect("operation detail must expose an ETag") + .to_str() + .unwrap() + .to_owned() +} + +pub(super) async fn agent_etag( + client: &reqwest::Client, + workspace_base_url: impl AsRef, + agent_id: &str, +) -> String { + let response = client + .get(format!("{}/agents/{agent_id}", workspace_base_url.as_ref())) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + response + .headers() + .get(reqwest::header::ETAG) + .expect("agent detail must expose an ETag") + .to_str() + .unwrap() + .to_owned() } pub(super) async fn assert_success_json(response: reqwest::Response) -> Value { @@ -216,6 +300,16 @@ pub(super) async fn test_registry() -> PostgresRegistry { .await .unwrap(); let registry = PostgresRegistry::connect(&database_url).await.unwrap(); + let secret_crypto = test_secret_crypto(); + registry + .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { + epoch: secret_crypto.master_key_epoch(), + fingerprint: secret_crypto.master_key_fingerprint(), + cipher_contract: MASTER_KEY_CIPHER_CONTRACT, + observed_at: &time::OffsetDateTime::now_utc(), + }) + .await + .unwrap(); let password_hash = hash_password(TEST_AUTH_PASSWORD, TEST_PASSWORD_PEPPER).unwrap(); let user_id = registry .upsert_bootstrap_user(TEST_AUTH_EMAIL, "Test Owner", &password_hash) diff --git a/apps/admin-api/tests/integration/community_access_usage.rs b/apps/admin-api/tests/integration/community_access_usage.rs index 02665fb..8475e1e 100644 --- a/apps/admin-api/tests/integration/community_access_usage.rs +++ b/apps/admin-api/tests/integration/community_access_usage.rs @@ -39,7 +39,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local"; const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_SESSION_SECRET: &str = "test-session-secret"; -const TEST_MASTER_KEY: &str = "test-master-key"; +const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000"; struct TestServer { base_url: String, @@ -478,6 +478,11 @@ async fn seeds_demo_assets_for_live_ui() { &WorkspaceId::new(DEFAULT_WORKSPACE_ID), &smoke_operation_id, smoke_operation.version, + &crank_registry::OperationStateExpectation { + current_draft_version: smoke_operation.version, + status: crank_core::OperationStatus::Draft, + latest_published_version: None, + }, ) .await .unwrap(); @@ -508,6 +513,7 @@ async fn seeds_demo_assets_for_live_ui() { enabled: true, }] .into(), + None, ) .await .unwrap(); @@ -516,6 +522,7 @@ async fn seeds_demo_assets_for_live_ui() { &WorkspaceId::new(DEFAULT_WORKSPACE_ID), &smoke_agent_id, smoke_agent.version, + None, ) .await .unwrap(); @@ -573,17 +580,22 @@ async fn seeds_demo_assets_for_live_ui() { &default_workspace_id, admin_api::service::LogsQuery { level: None, + status: None, search: None, source: None, operation_id: None, agent_id: None, + outcome_group: None, + created_after: None, + created_before: None, period: None, + cursor: None, limit: Some(20), }, ) .await .unwrap(); - assert!(!logs.is_empty()); + assert!(!logs.items.is_empty()); } #[tokio::test(flavor = "multi_thread")] @@ -646,9 +658,11 @@ async fn updates_profile_and_changes_password() { updated_profile["user"]["email"], "updated-owner@crank.local" ); + let updated_csrf_token = updated_profile["csrf_token"].as_str().unwrap(); let password_status = client .post(format!("{root_url}/api/auth/password")) + .header("x-csrf-token", updated_csrf_token) .json(&json!({ "current_password": TEST_AUTH_PASSWORD, "new_password": "updated-password-123" @@ -671,7 +685,7 @@ async fn updates_profile_and_changes_password() { .await .unwrap() .status(); - assert_eq!(current_session_status, reqwest::StatusCode::OK); + assert_eq!(current_session_status, reqwest::StatusCode::UNAUTHORIZED); assert_eq!(other_session_status, reqwest::StatusCode::UNAUTHORIZED); let relogin_client = reqwest::Client::builder() @@ -741,87 +755,6 @@ async fn rejects_multi_workspace_session_switching_in_community() { assert_eq!(session["current_workspace_id"], DEFAULT_WORKSPACE_ID); } -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn exposes_logs_and_usage_from_real_test_runs() { - let registry = test_registry().await; - let storage_root = test_storage_root("observability"); - let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_operation_payload( - &upstream_base_url, - "crm_observability", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - client - .post(format!("{base_url}/operations/{operation_id}/test-runs")) - .json(&json!({ - "version": 1, - "input": { "email": "user@example.com" } - })) - .send() - .await - .unwrap(); - - let logs = client - .get(format!("{base_url}/logs?period=7d")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let log_id = logs["items"][0]["log"]["id"].as_str().unwrap().to_owned(); - let log_detail = client - .get(format!("{base_url}/logs/{log_id}")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let usage = client - .get(format!("{base_url}/usage?period=7d")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_usage = client - .get(format!( - "{base_url}/usage/operations/{operation_id}?period=7d" - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert_eq!(logs["items"][0]["log"]["source"], "admin_test_run"); - assert_eq!(logs["items"][0]["operation_name"], "crm_observability"); - assert_eq!(log_detail["log"]["status"], "ok"); - assert_eq!(usage["summary"]["rollup"]["calls_total"], 1); - assert_eq!(usage["summary"]["rollup"]["calls_ok"], 1); - assert_eq!( - usage["operations"][0]["operation_name"], - "crm_observability" - ); - assert_eq!(operation_usage["rollup"]["calls_total"], 1); -} - #[tokio::test(flavor = "multi_thread")] #[serial] async fn preserves_request_id_for_test_run_invocations() { diff --git a/apps/admin-api/tests/integration/credential_lifecycle.rs b/apps/admin-api/tests/integration/credential_lifecycle.rs new file mode 100644 index 0000000..470b6aa --- /dev/null +++ b/apps/admin-api/tests/integration/credential_lifecycle.rs @@ -0,0 +1,994 @@ +#![allow(dead_code, unused_imports)] + +use super::common::*; + +use std::sync::{Arc, Mutex}; + +use axum::{Json, Router, http::HeaderMap, routing::post}; +use crank_core::{AuditError, AuditEvent, AuditSink, PlatformApiKeyKind, SecretKind}; +use serde_json::{Value, json}; +use serial_test::serial; +use tokio::net::TcpListener; + +#[derive(Clone, Default)] +struct RecordingAuditSink { + events: Arc>>, +} + +impl RecordingAuditSink { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +#[async_trait::async_trait] +impl AuditSink for RecordingAuditSink { + async fn record(&self, event: AuditEvent) -> Result<(), AuditError> { + self.events.lock().unwrap().push(event); + Ok(()) + } +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn machine_key_raw_value_is_create_only_and_duplicate_name_is_typed_conflict() { + let registry = test_registry().await; + let storage_root = test_storage_root("credential_lifecycle_keys"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let agent_id = create_agent(&client, &base_url, "credential-lifecycle-agent").await; + + let created = post_json( + &client, + format!("{base_url}/agents/{agent_id}/platform-api-keys"), + json!({"name": "primary-mcp-key", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read", "write"]}), + ) + .await; + let raw_key = created["secret"] + .as_str() + .expect("create response must disclose the raw key once") + .to_owned(); + let random_part = raw_key + .strip_prefix("crk_") + .expect("MCP key must use the MCP bearer marker"); + assert_eq!(random_part.len(), 43, "32 raw bytes in unpadded base64url"); + assert!( + random_part + .bytes() + .all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') }) + ); + assert_eq!( + created["connection"]["endpoint"], + "http://localhost:3000/mcp/v1/default/credential-lifecycle-agent" + ); + assert!( + created["connection"]["clients"] + .as_array() + .is_some_and(|clients| !clients.is_empty()) + ); + + let approval = post_json( + &client, + format!("{base_url}/agents/{agent_id}/platform-api-keys"), + json!({"name": "approval-without-mcp-config", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"]}), + ) + .await; + assert!(approval.get("connection").is_none()); + + let listed_text = client + .get(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(!listed_text.contains(&raw_key)); + assert!(!listed_text.contains("secret_hash")); + assert!(!listed_text.contains("\"secret\"")); + + let duplicate = client + .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .json(&json!({ + "name": "primary-mcp-key", + "key_kind": PlatformApiKeyKind::McpClient, + "scopes": ["read"] + })) + .send() + .await + .unwrap(); + assert_eq!(duplicate.status(), reqwest::StatusCode::CONFLICT); + let duplicate_body = duplicate.text().await.unwrap(); + assert!(!duplicate_body.contains(&raw_key)); + assert!(!duplicate_body.contains("secret_hash")); + assert!(!duplicate_body.contains("duplicate key value")); + let duplicate_json: Value = serde_json::from_str(&duplicate_body).unwrap(); + assert_eq!( + duplicate_json["error"]["code"], + "platform_api_key_name_conflict" + ); + + for payload in [ + json!({"name": "n".repeat(129), "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read"]}), + json!({"name": "duplicate-scope", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read", "read"]}), + json!({"name": "past-expiry", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read"], "expires_at": "2020-01-01T00:00:00Z"}), + ] { + assert_post_error( + &client, + format!("{base_url}/agents/{agent_id}/platform-api-keys"), + payload, + reqwest::StatusCode::BAD_REQUEST, + "validation_error", + ) + .await; + } + + let canonical = post_json( + &client, + format!("{base_url}/agents/{agent_id}/platform-api-keys"), + json!({"name": "canonical-approval-origin", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": ["HTTPS://Example.COM:443/", "http://EXAMPLE.com:80/"]}), + ) + .await; + assert_eq!( + canonical["api_key"]["api_key"]["allowed_origins"], + json!(["https://example.com", "http://example.com"]) + ); + for (index, origin) in [ + "https://[::1", + "https://example.test:bad/", + "https://user:pass@example.test/", + "https://example.test/path", + "https://example.test/?token=origin-canary", + "https://example.test/#fragment", + ] + .into_iter() + .enumerate() + { + assert_post_error( + &client, + format!("{base_url}/agents/{agent_id}/platform-api-keys"), + json!({"name": format!("malformed-origin-{index}"), "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": [origin]}), + reqwest::StatusCode::BAD_REQUEST, + "validation_error", + ) + .await; + } + assert_post_error( + &client, + format!("{base_url}/agents/{agent_id}/platform-api-keys"), + json!({"name": "duplicate-canonical-origin", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": ["https://EXAMPLE.test:443/", "https://example.test/"]}), + reqwest::StatusCode::BAD_REQUEST, + "validation_error", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn deleted_machine_key_name_can_be_reused_and_terminal_state_cannot_regress() { + let registry = test_registry().await; + let storage_root = test_storage_root("credential_lifecycle_key_reuse"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let agent_id = create_agent(&client, &base_url, "credential-key-reuse-agent").await; + + let first = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .json(&json!({ + "name": "replaceable-key", + "key_kind": PlatformApiKeyKind::McpClient, + "scopes": ["read"] + })) + .send() + .await + .unwrap(), + ) + .await; + let first_key_id = first["api_key"]["api_key"]["id"].as_str().unwrap(); + + let delete_response = client + .delete(format!( + "{base_url}/agents/{agent_id}/platform-api-keys/{first_key_id}" + )) + .send() + .await + .unwrap(); + assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT); + + let revoke_deleted = client + .post(format!( + "{base_url}/agents/{agent_id}/platform-api-keys/{first_key_id}/revoke" + )) + .send() + .await + .unwrap(); + assert_eq!(revoke_deleted.status(), reqwest::StatusCode::CONFLICT); + let revoke_body: Value = serde_json::from_str(&revoke_deleted.text().await.unwrap()).unwrap(); + assert_eq!(revoke_body["error"]["code"], "platform_api_key_not_active"); + + let second = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .json(&json!({ + "name": "replaceable-key", + "key_kind": PlatformApiKeyKind::McpClient, + "scopes": ["read"] + })) + .send() + .await + .unwrap(), + ) + .await; + let second_key_id = second["api_key"]["api_key"]["id"].as_str().unwrap(); + assert_ne!(first_key_id, second_key_id); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn secret_plaintext_is_write_only_and_auth_profile_stores_reference() { + let registry = test_registry().await; + let storage_root = test_storage_root("credential_lifecycle_secrets"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + let canary = "secret-canary-1-10-do-not-leak"; + + let created_response = client + .post(format!("{base_url}/secrets")) + .json(&json!({ + "name": "crm-bearer-token", + "kind": SecretKind::Token, + "value": { "token": canary } + })) + .send() + .await + .unwrap(); + let created_text = created_response.text().await.unwrap(); + assert!(!created_text.contains(canary)); + assert!(!created_text.contains("ciphertext")); + let created: Value = serde_json::from_str(&created_text).unwrap(); + let secret_id = created["id"].as_str().unwrap(); + + let listed_text = client + .get(format!("{base_url}/secrets")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(!listed_text.contains(canary)); + assert!(!listed_text.contains("ciphertext")); + + let fetched_text = client + .get(format!("{base_url}/secrets/{secret_id}")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(!fetched_text.contains(canary)); + assert!(!fetched_text.contains("ciphertext")); + + let auth_profile_text = client + .post(format!("{base_url}/auth-profiles")) + .json(&json!({ + "name": "crm-bearer", + "kind": "bearer", + "config": { + "bearer": { + "header_name": "Authorization", + "secret_id": secret_id + } + } + })) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(!auth_profile_text.contains(canary)); + assert!(!auth_profile_text.contains("ciphertext")); + let auth_profile: Value = serde_json::from_str(&auth_profile_text).unwrap(); + assert_eq!(auth_profile["config"]["bearer"]["secret_id"], secret_id); + + for (name, kind, value) in [ + ("null-token", SecretKind::Token, Value::Null), + ( + "wrong-token-shape", + SecretKind::Token, + json!({"token": "token", "extra": "field"}), + ), + ( + "wrong-header-shape", + SecretKind::Header, + json!({"header_name": "X-Token"}), + ), + ( + "wrong-username-password-shape", + SecretKind::UsernamePassword, + json!({"username": "user"}), + ), + ] { + assert_post_error( + &client, + format!("{base_url}/secrets"), + json!({"name": name, "kind": kind, "value": value}), + reqwest::StatusCode::BAD_REQUEST, + "validation_error", + ) + .await; + } + assert_post_error( + &client, + format!("{base_url}/secrets"), + json!({"name": "oversized-secret", "kind": SecretKind::Generic, "value": "x".repeat(65_537)}), + reqwest::StatusCode::BAD_REQUEST, + "validation_error", + ) + .await; + for value in [ + Value::Null, + json!({"token": "token", "extra": "field"}), + json!("x".repeat(65_537)), + ] { + assert_post_error( + &client, + format!("{base_url}/secrets/{secret_id}/rotate"), + json!({"value": value}), + reqwest::StatusCode::BAD_REQUEST, + "validation_error", + ) + .await; + } + let current = assert_success_json( + client + .get(format!("{base_url}/secrets/{secret_id}")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(current["current_version"], 1); + + for payload in [ + json!({"name": "", "kind": "bearer", "config": {"bearer": {"header_name": "Authorization", "secret_id": secret_id}}}), + json!({"name": "n".repeat(129), "kind": "bearer", "config": {"bearer": {"header_name": "Authorization", "secret_id": secret_id}}}), + json!({"name": "invalid-header-name", "kind": "bearer", "config": {"bearer": {"header_name": "Bad Header", "secret_id": secret_id}}}), + json!({"name": "invalid-query-name", "kind": "api_key_query", "config": {"api_key_query": {"param_name": "bad name", "secret_id": secret_id}}}), + ] { + assert_post_error( + &client, + format!("{base_url}/auth-profiles"), + payload, + reqwest::StatusCode::BAD_REQUEST, + "validation_error", + ) + .await; + } +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn credential_mutations_emit_bounded_non_secret_audit_events() { + let registry = test_registry().await; + let storage_root = test_storage_root("credential_lifecycle_audit"); + let audit_sink = RecordingAuditSink::default(); + let base_url = spawn_admin_api(build_test_app_with_audit_sink( + registry, + storage_root, + Arc::new(audit_sink.clone()), + )) + .await; + let client = authorized_client(&base_url).await; + let request_id = "req_credential_audit_1"; + let traceparent = "00-11111111111111111111111111111111-2222222222222222-01"; + + let secret = assert_success_json( + client + .post(format!("{base_url}/secrets")) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .json(&json!({ + "name": "audit-token", + "kind": SecretKind::Token, + "value": { "token": "audit-secret-canary" } + })) + .send() + .await + .unwrap(), + ) + .await; + let secret_id = secret["id"].as_str().unwrap(); + + assert_success_json( + client + .post(format!("{base_url}/secrets/{secret_id}/rotate")) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .json(&json!({ + "value": { "token": "audit-rotated-canary" } + })) + .send() + .await + .unwrap(), + ) + .await; + + let auth_profile = assert_success_json( + client + .post(format!("{base_url}/auth-profiles")) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .json(&json!({ + "name": "audit-profile", + "kind": "bearer", + "config": { + "bearer": { + "header_name": "Authorization", + "secret_id": secret_id + } + } + })) + .send() + .await + .unwrap(), + ) + .await; + let auth_profile_id = auth_profile["id"].as_str().unwrap(); + + let delete_denied = client + .delete(format!("{base_url}/secrets/{secret_id}")) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .send() + .await + .unwrap(); + assert_eq!(delete_denied.status(), reqwest::StatusCode::CONFLICT); + + let created_agent = assert_success_json( + client + .post(format!("{base_url}/agents")) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .json(&json!({ + "slug": "credential-audit-agent", + "display_name": "Credential Audit Agent", + "description": "Agent for credential audit tests.", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let agent_id = created_agent["agent_id"].as_str().unwrap(); + let created_key = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .json(&json!({ + "name": "audit-mcp-key", + "key_kind": PlatformApiKeyKind::McpClient, + "scopes": ["read"] + })) + .send() + .await + .unwrap(), + ) + .await; + let raw_key = created_key["secret"].as_str().unwrap(); + let key_id = created_key["api_key"]["api_key"]["id"].as_str().unwrap(); + let duplicate_key = client + .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .json(&json!({ + "name": "audit-mcp-key", + "key_kind": PlatformApiKeyKind::McpClient, + "scopes": ["read"] + })) + .send() + .await + .unwrap(); + assert_eq!(duplicate_key.status(), reqwest::StatusCode::CONFLICT); + let validation_key = client + .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .json(&json!({ + "name": "n".repeat(129), + "key_kind": PlatformApiKeyKind::McpClient, + "scopes": ["read"] + })) + .send() + .await + .unwrap(); + assert_eq!(validation_key.status(), reqwest::StatusCode::BAD_REQUEST); + let invalid_profile = client + .post(format!("{base_url}/auth-profiles")) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .json(&json!({ + "name": "audit-invalid-profile", + "kind": "bearer", + "config": { + "bearer": { + "header_name": "Authorization", + "secret_id": "secret_missing_for_audit" + } + } + })) + .send() + .await + .unwrap(); + assert_eq!(invalid_profile.status(), reqwest::StatusCode::NOT_FOUND); + client + .post(format!( + "{base_url}/agents/{agent_id}/platform-api-keys/{key_id}/revoke" + )) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .send() + .await + .unwrap(); + let delete_response = client + .delete(format!( + "{base_url}/agents/{agent_id}/platform-api-keys/{key_id}" + )) + .header("x-request-id", request_id) + .header("traceparent", traceparent) + .send() + .await + .unwrap(); + assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT); + + let listed_after_delete = assert_success_json( + client + .get(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .send() + .await + .unwrap(), + ) + .await; + let deleted_metadata = listed_after_delete["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["api_key"]["id"] == key_id) + .expect("delete must preserve key metadata for provenance"); + assert_eq!(deleted_metadata["api_key"]["status"], "deleted"); + let listed_after_delete_text = serde_json::to_string(&listed_after_delete).unwrap(); + assert!(!listed_after_delete_text.contains(raw_key)); + + let events = audit_sink.events(); + let actions: Vec<&str> = events.iter().map(|event| event.action.as_str()).collect(); + for expected in [ + "credential.secret.created", + "credential.secret.rotated", + "credential.auth_profile.created", + "credential.secret.delete_denied", + "credential.platform_api_key.created", + "credential.platform_api_key.create_failed", + "credential.auth_profile.create_failed", + "credential.platform_api_key.revoked", + "credential.platform_api_key.deleted", + ] { + assert!( + actions.contains(&expected), + "missing audit action {expected}" + ); + } + + let audit_json = serde_json::to_string(&events).unwrap(); + assert!(audit_json.contains(request_id)); + assert!(audit_json.contains("11111111111111111111111111111111")); + assert!(audit_json.contains(auth_profile_id)); + assert!(!audit_json.contains("audit-secret-canary")); + assert!(!audit_json.contains("audit-rotated-canary")); + assert!(!audit_json.contains(raw_key)); + assert!(!audit_json.contains("secret_hash")); + assert!(!audit_json.contains("ciphertext")); + assert!(!audit_json.contains("Bearer ")); + + for event in &events { + let reason = event.payload["reason"] + .as_str() + .expect("credential audit events must contain a reason code"); + assert!(!reason.is_empty()); + assert!(reason.len() <= 128, "audit reason must remain bounded"); + } + assert!(events.iter().any(|event| { + event.action == "credential.platform_api_key.create_failed" + && event.payload["reason"] == "validation_error" + })); + assert!(events.iter().any(|event| { + event.action == "credential.platform_api_key.create_failed" + && event.payload["reason"] == "platform_api_key_name_conflict" + })); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn disabled_secret_fails_closed_for_reference_rotation_and_execution() { + let registry = test_registry().await; + let registry_control = registry.clone(); + let storage_root = test_storage_root("credential_lifecycle_disabled_secret"); + let (upstream_base_url, observed_authorizations) = spawn_auth_capture_upstream().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let secret = assert_success_json( + client + .post(format!("{base_url}/secrets")) + .json(&json!({ + "name": "disabled-token", + "kind": SecretKind::Token, + "value": { "token": "disabled-secret-canary" } + })) + .send() + .await + .unwrap(), + ) + .await; + let secret_id = secret["id"].as_str().unwrap(); + + let auth_profile = assert_success_json( + client + .post(format!("{base_url}/auth-profiles")) + .json(&json!({ + "name": "disabled-profile", + "kind": "bearer", + "config": { + "bearer": { + "header_name": "Authorization", + "secret_id": secret_id + } + } + })) + .send() + .await + .unwrap(), + ) + .await; + let auth_profile_id = auth_profile["id"].as_str().unwrap(); + + let mut operation_payload = serde_json::to_value(test_operation_payload( + &upstream_base_url, + "disabled_secret_execution", + )) + .unwrap(); + operation_payload["execution_config"]["auth_profile_ref"] = json!(auth_profile_id); + let created_operation = assert_success_json( + client + .post(format!("{base_url}/operations")) + .json(&operation_payload) + .send() + .await + .unwrap(), + ) + .await; + let operation_id = created_operation["operation_id"].as_str().unwrap(); + + sqlx::query("update secrets set status = 'disabled' where id = $1") + .bind(secret_id) + .execute(registry_control.pool()) + .await + .unwrap(); + + let ref_disabled = client + .post(format!("{base_url}/auth-profiles")) + .json(&json!({ + "name": "disabled-profile-new-ref", + "kind": "bearer", + "config": { + "bearer": { + "header_name": "Authorization", + "secret_id": secret_id + } + } + })) + .send() + .await + .unwrap(); + assert_eq!(ref_disabled.status(), reqwest::StatusCode::CONFLICT); + let ref_disabled_body: Value = + serde_json::from_str(&ref_disabled.text().await.unwrap()).unwrap(); + assert_eq!(ref_disabled_body["error"]["code"], "secret_not_active"); + + let rotate_disabled = client + .post(format!("{base_url}/secrets/{secret_id}/rotate")) + .json(&json!({ + "value": { "token": "should-not-rotate" } + })) + .send() + .await + .unwrap(); + assert_eq!(rotate_disabled.status(), reqwest::StatusCode::CONFLICT); + let rotate_body: Value = serde_json::from_str(&rotate_disabled.text().await.unwrap()).unwrap(); + assert_eq!(rotate_body["error"]["code"], "secret_not_active"); + + let test_run = assert_success_json( + client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "email": "disabled@example.com" } + })) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(test_run["ok"], false); + let body = serde_json::to_string(&test_run).unwrap(); + assert!(!body.contains("disabled-secret-canary")); + assert!(observed_authorizations.lock().unwrap().is_empty()); + + let operation_get = client + .get(format!("{base_url}/operations/{operation_id}")) + .send() + .await + .unwrap(); + let operation_etag = operation_get + .headers() + .get(reqwest::header::ETAG) + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let publish_disabled = client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .header(reqwest::header::IF_MATCH, operation_etag) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(); + assert_eq!(publish_disabled.status(), reqwest::StatusCode::CONFLICT); + let publish_body: Value = + serde_json::from_str(&publish_disabled.text().await.unwrap()).unwrap(); + assert_eq!(publish_body["error"]["code"], "secret_not_active"); + assert!(observed_authorizations.lock().unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn concurrent_secret_rotation_serializes_versions_without_storage_conflict() { + let registry = test_registry().await; + let storage_root = test_storage_root("credential_lifecycle_concurrent_rotation"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let secret = assert_success_json( + client + .post(format!("{base_url}/secrets")) + .json(&json!({ + "name": "concurrent-token", + "kind": SecretKind::Token, + "value": { "token": "initial-concurrent-token" } + })) + .send() + .await + .unwrap(), + ) + .await; + let secret_id = secret["id"].as_str().unwrap().to_owned(); + + let first = client + .post(format!("{base_url}/secrets/{secret_id}/rotate")) + .json(&json!({ "value": { "token": "concurrent-token-a" } })) + .send(); + let second = client + .post(format!("{base_url}/secrets/{secret_id}/rotate")) + .json(&json!({ "value": { "token": "concurrent-token-b" } })) + .send(); + let (first, second) = tokio::join!(first, second); + let first = assert_success_json(first.unwrap()).await; + let second = assert_success_json(second.unwrap()).await; + assert!(first["current_version"].as_u64().unwrap() >= 2); + assert!(second["current_version"].as_u64().unwrap() >= 2); + let current = assert_success_json( + client + .get(format!("{base_url}/secrets/{secret_id}")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(current["current_version"], 3); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn auth_profile_secret_rotation_changes_next_admin_test_execution() { + let registry = test_registry().await; + let storage_root = test_storage_root("credential_lifecycle_rotation"); + let (upstream_base_url, observed_authorizations) = spawn_auth_capture_upstream().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let secret = assert_success_json( + client + .post(format!("{base_url}/secrets")) + .json(&json!({ + "name": "crm-rotating-token", + "kind": SecretKind::Token, + "value": { "token": "initial-rotation-token" } + })) + .send() + .await + .unwrap(), + ) + .await; + let secret_id = secret["id"].as_str().unwrap(); + + let auth_profile = assert_success_json( + client + .post(format!("{base_url}/auth-profiles")) + .json(&json!({ + "name": "crm-rotating-bearer", + "kind": "bearer", + "config": { + "bearer": { + "header_name": "Authorization", + "secret_id": secret_id + } + } + })) + .send() + .await + .unwrap(), + ) + .await; + let auth_profile_id = auth_profile["id"].as_str().unwrap(); + + let mut operation_payload = serde_json::to_value(test_operation_payload( + &upstream_base_url, + "crm_auth_rotation", + )) + .unwrap(); + operation_payload["execution_config"]["auth_profile_ref"] = json!(auth_profile_id); + let created_operation = assert_success_json( + client + .post(format!("{base_url}/operations")) + .json(&operation_payload) + .send() + .await + .unwrap(), + ) + .await; + let operation_id = created_operation["operation_id"].as_str().unwrap(); + + let first_run = assert_success_json( + client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "email": "first@example.com" } + })) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(first_run["ok"], true); + + let rotated = assert_success_json( + client + .post(format!("{base_url}/secrets/{secret_id}/rotate")) + .json(&json!({ + "value": { "token": "rotated-rotation-token" } + })) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(rotated["current_version"], 2); + + let second_run = assert_success_json( + client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "email": "second@example.com" } + })) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(second_run["ok"], true); + + let captured = observed_authorizations.lock().unwrap().clone(); + assert_eq!( + captured, + vec![ + Some("Bearer initial-rotation-token".to_owned()), + Some("Bearer rotated-rotation-token".to_owned()) + ] + ); + + let auth_profile_after = client + .get(format!("{base_url}/auth-profiles/{auth_profile_id}")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(auth_profile_after.contains(secret_id)); + assert!(!auth_profile_after.contains("initial-rotation-token")); + assert!(!auth_profile_after.contains("rotated-rotation-token")); +} + +async fn spawn_auth_capture_upstream() -> (String, Arc>>>) { + let observed_authorizations = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&observed_authorizations); + let app = Router::new().route( + "/crm/leads", + post(move |headers: HeaderMap| { + let captured = Arc::clone(&captured); + async move { + let authorization = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + captured.lock().unwrap().push(authorization); + Json(json!({ "id": "lead_123" })) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (format!("http://{address}"), observed_authorizations) +} + +async fn assert_error_code(response: reqwest::Response, status: reqwest::StatusCode, code: &str) { + assert_eq!(response.status(), status); + let body = response.text().await.unwrap(); + let json: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["error"]["code"], code, "unexpected error body: {body}"); +} + +async fn post_json(client: &reqwest::Client, url: String, payload: Value) -> Value { + assert_success_json(client.post(url).json(&payload).send().await.unwrap()).await +} + +async fn create_agent(client: &reqwest::Client, base_url: impl AsRef, slug: &str) -> String { + let base_url = base_url.as_ref(); + post_json( + client, + format!("{base_url}/agents"), + json!({"slug": slug, "display_name": slug, "description": "Credential lifecycle test agent.", "instructions": {}, "tool_selection_policy": {}}), + ) + .await["agent_id"] + .as_str() + .unwrap() + .to_owned() +} + +async fn assert_post_error( + client: &reqwest::Client, + url: String, + payload: Value, + status: reqwest::StatusCode, + code: &str, +) { + assert_error_code( + client.post(url).json(&payload).send().await.unwrap(), + status, + code, + ) + .await; +} diff --git a/apps/admin-api/tests/integration/logs_usage.rs b/apps/admin-api/tests/integration/logs_usage.rs new file mode 100644 index 0000000..94c2529 --- /dev/null +++ b/apps/admin-api/tests/integration/logs_usage.rs @@ -0,0 +1,152 @@ +#![allow(dead_code, unused_imports)] + +use super::common::*; + +use serde_json::{Value, json}; +use serial_test::serial; +use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn exposes_logs_and_usage_from_real_test_runs() { + let registry = test_registry().await; + let storage_root = test_storage_root("observability"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_observability", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "email": "user@example.com" } + })) + .send() + .await + .unwrap(); + + let logs = client + .get(format!("{base_url}/logs?period=7d")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert!(logs["items"].is_array()); + assert!(logs.get("next_cursor").is_some()); + let log_id = logs["items"][0]["log"]["id"].as_str().unwrap().to_owned(); + let created_at = OffsetDateTime::parse( + logs["items"][0]["log"]["created_at"].as_str().unwrap(), + &Rfc3339, + ) + .unwrap(); + let log_detail = client + .get(format!("{base_url}/logs/{log_id}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let csv = client + .get(format!("{base_url}/logs/export.csv?period=7d&status=ok")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + let outcome_logs = client + .get(format!("{base_url}/logs?period=7d&outcome_group=success")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let created_after = (created_at - Duration::minutes(1)) + .format(&Rfc3339) + .unwrap(); + let created_before = (created_at + Duration::minutes(1)) + .format(&Rfc3339) + .unwrap(); + let usage = client + .get(format!("{base_url}/usage?period=7d")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let windowed_usage = client + .get(format!( + "{base_url}/usage?created_after={created_after}&created_before={created_before}" + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let usage_csv = client + .get(format!("{base_url}/usage/export.csv?period=7d")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + let operation_usage = client + .get(format!( + "{base_url}/usage/operations/{operation_id}?period=7d" + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(logs["items"][0]["log"]["source"], "admin_test_run"); + assert_eq!(logs["items"][0]["operation_name"], "crm_observability"); + assert_eq!(log_detail["log"]["status"], "ok"); + assert_eq!(log_detail["log"]["operation_version"], 1); + assert!(log_detail["log"]["request_id"].as_str().is_some()); + assert!( + log_detail["log"]["trace_id"] + .as_str() + .is_some_and(|value| value.len() == 32) + ); + assert!(csv.starts_with("created_at,level,status,source")); + assert!(csv.contains(log_detail["log"]["request_id"].as_str().unwrap())); + assert!(csv.contains(log_detail["log"]["trace_id"].as_str().unwrap())); + assert_eq!(outcome_logs["items"][0]["log"]["status"], "ok"); + assert_eq!(usage["summary"]["rollup"]["calls_total"], 1); + assert_eq!(usage["summary"]["rollup"]["calls_ok"], 1); + assert_eq!(usage["outcomes"][0]["group"], "success"); + assert_eq!(usage["outcomes"][0]["calls_total"], 1); + assert_eq!(windowed_usage["summary"]["rollup"]["calls_total"], 1); + assert!(usage_csv.starts_with("kind,name,group,error_code,calls_total")); + assert!(usage_csv.contains("operation")); + assert!(usage_csv.contains("outcome")); + assert_eq!( + usage["operations"][0]["operation_name"], + "crm_observability" + ); + assert_eq!(operation_usage["rollup"]["calls_total"], 1); +} diff --git a/apps/admin-api/tests/integration/onboarding.rs b/apps/admin-api/tests/integration/onboarding.rs new file mode 100644 index 0000000..36b3d05 --- /dev/null +++ b/apps/admin-api/tests/integration/onboarding.rs @@ -0,0 +1,322 @@ +#![allow(dead_code, unused_imports)] + +use super::common::*; + +use serde_json::{Value, json}; +use serial_test::serial; + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn exposes_server_authoritative_onboarding_projection() { + let registry = test_registry().await; + let storage_root = test_storage_root("onboarding_projection"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let response = client + .get(format!("{base_url}/onboarding")) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::OK, "body={body}"); + assert_eq!(body["schema_version"], 1); + assert_eq!(body["status"], "in_progress"); + assert!(body["revision"].as_i64().is_some()); + assert!(body["eligible_since"].as_str().is_some()); + assert_eq!(body["workspace_id"], "ws_default"); + assert_eq!(body["completed"], false); + assert_eq!( + body["steps"] + .as_array() + .unwrap() + .iter() + .map(|step| step["id"].as_str().unwrap()) + .collect::>(), + vec![ + "operation", + "test", + "publish_operation", + "agent", + "key", + "mcp_connection", + "first_call", + ] + ); + assert!( + body["steps"] + .as_array() + .unwrap() + .iter() + .all(|step| step["completed"] == false) + ); + assert_eq!(body["steps"][0]["status"], "current"); + assert!( + body["steps"] + .as_array() + .unwrap() + .iter() + .skip(1) + .all(|step| step["status"] == "pending") + ); + + // The server-owned eligibility denominator is recorded on the first GET; + // a repeat is idempotent and cannot advance browser-owned completion. + let repeated = client + .get(format!("{base_url}/onboarding")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(repeated["revision"], body["revision"]); + assert_eq!(repeated["steps"], body["steps"]); + assert_eq!(repeated["eligible_since"], body["eligible_since"]); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn ux_milestones_are_idempotent_and_revision_guarded() { + let registry = test_registry().await; + let storage_root = test_storage_root("onboarding_events"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let initial = client + .get(format!("{base_url}/onboarding")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let revision = initial["revision"].as_i64().unwrap(); + let payload = json!({ + "event": "started", + "idempotency_key": "onboarding-start-tab-a", + "expected_revision": revision, + }); + + let first = client + .post(format!("{base_url}/onboarding/events")) + .json(&payload) + .send() + .await + .unwrap(); + let first_status = first.status(); + let first = first.json::().await.unwrap(); + assert_eq!(first_status, reqwest::StatusCode::OK, "body={first}"); + assert_eq!(first["accepted"], true); + let accepted_revision = first["revision"].as_i64().unwrap(); + assert_ne!(accepted_revision, revision); + + let replay = client + .post(format!("{base_url}/onboarding/events")) + .json(&payload) + .send() + .await + .unwrap(); + let replay_status = replay.status(); + let replay = replay.json::().await.unwrap(); + assert_eq!(replay_status, reqwest::StatusCode::OK, "body={replay}"); + assert_eq!(replay["accepted"], false); + assert_eq!(replay["revision"], accepted_revision); + + let semantic_conflict = client + .post(format!("{base_url}/onboarding/events")) + .json(&json!({ + "event": "dismissed", + "idempotency_key": "onboarding-start-tab-a", + "expected_revision": revision, + })) + .send() + .await + .unwrap(); + let semantic_conflict_status = semantic_conflict.status(); + let semantic_conflict = semantic_conflict.json::().await.unwrap(); + assert_eq!( + semantic_conflict_status, + reqwest::StatusCode::CONFLICT, + "body={semantic_conflict}" + ); + assert_eq!( + semantic_conflict["error"]["code"], + "onboarding_idempotency_conflict" + ); + + let current_revision_conflict = client + .post(format!("{base_url}/onboarding/events")) + .json(&json!({ + "event": "dismissed", + "idempotency_key": "onboarding-start-tab-a", + "expected_revision": accepted_revision, + })) + .send() + .await + .unwrap(); + let current_revision_conflict_status = current_revision_conflict.status(); + let current_revision_conflict = current_revision_conflict.json::().await.unwrap(); + assert_eq!( + current_revision_conflict_status, + reqwest::StatusCode::CONFLICT, + "body={current_revision_conflict}" + ); + assert_eq!( + current_revision_conflict["error"]["code"], + "onboarding_idempotency_conflict" + ); + + let stale = client + .post(format!("{base_url}/onboarding/events")) + .json(&json!({ + "event": "dismissed", + "idempotency_key": "onboarding-dismiss-tab-b", + "expected_revision": revision, + })) + .send() + .await + .unwrap(); + let stale_status = stale.status(); + let stale = stale.json::().await.unwrap(); + assert_eq!(stale_status, reqwest::StatusCode::CONFLICT, "body={stale}"); + assert_eq!(stale["error"]["code"], "onboarding_stale_revision"); + + for payload in [ + json!({ + "event": "eligible", + "idempotency_key": "forged-eligibility", + "expected_revision": accepted_revision, + }), + json!({ + "event": "completed", + "idempotency_key": "forged-completion", + "expected_revision": accepted_revision, + }), + json!({ + "event": "started", + "idempotency_key": "forged-step", + "expected_revision": accepted_revision, + "completed_steps": ["operation", "first_call"], + }), + json!({ + "event": "started", + "idempotency_key": "onboarding:completed:v1", + "expected_revision": accepted_revision, + }), + ] { + let response = client + .post(format!("{base_url}/onboarding/events")) + .json(&payload) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!( + status, + reqwest::StatusCode::UNPROCESSABLE_ENTITY, + "body={body}" + ); + assert_eq!(body["error"]["code"], "onboarding_event_not_allowed"); + } +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn direct_presentation_mutation_cannot_precede_server_eligibility() { + let registry = test_registry().await; + let storage_root = test_storage_root("onboarding_direct_event"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let direct = client + .post(format!("{base_url}/onboarding/events")) + .json(&json!({ + "event": "started", + "idempotency_key": "direct-before-snapshot", + "expected_revision": 0, + })) + .send() + .await + .unwrap(); + let direct_status = direct.status(); + let direct = direct.json::().await.unwrap(); + assert_eq!( + direct_status, + reqwest::StatusCode::CONFLICT, + "body={direct}" + ); + assert_eq!(direct["error"]["code"], "onboarding_stale_revision"); + + let snapshot = client + .get(format!("{base_url}/onboarding")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert!(snapshot["eligible_since"].as_str().is_some()); + assert_eq!(snapshot["status"], "in_progress"); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn reset_selection_is_explicit_and_revision_guarded() { + let registry = test_registry().await; + let storage_root = test_storage_root("onboarding_reset_selection"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let initial = client + .get(format!("{base_url}/onboarding")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let revision = initial["revision"].as_i64().unwrap(); + + let reset = client + .post(format!("{base_url}/onboarding/reset-selection")) + .json(&json!({"expected_revision": revision})) + .send() + .await + .unwrap(); + let reset_status = reset.status(); + let reset = reset.json::().await.unwrap(); + assert_eq!(reset_status, reqwest::StatusCode::OK, "body={reset}"); + assert_eq!(reset["selection_reset"], true); + assert_ne!(reset["revision"], initial["revision"]); + assert_eq!(reset["workspace_id"], initial["workspace_id"]); + + let stale = client + .post(format!("{base_url}/onboarding/reset-selection")) + .json(&json!({"expected_revision": revision})) + .send() + .await + .unwrap(); + let stale_status = stale.status(); + let stale = stale.json::().await.unwrap(); + assert_eq!(stale_status, reqwest::StatusCode::CONFLICT, "body={stale}"); + assert_eq!(stale["error"]["code"], "onboarding_stale_revision"); + + let forged_selection = client + .post(format!("{base_url}/onboarding/reset-selection")) + .json(&json!({ + "expected_revision": reset["revision"], + "operation_id": "op_client_selected", + })) + .send() + .await + .unwrap(); + assert_eq!( + forged_selection.status(), + reqwest::StatusCode::UNPROCESSABLE_ENTITY + ); +} diff --git a/apps/admin-api/tests/integration/operation_lifecycle.rs b/apps/admin-api/tests/integration/operation_lifecycle.rs new file mode 100644 index 0000000..8660902 --- /dev/null +++ b/apps/admin-api/tests/integration/operation_lifecycle.rs @@ -0,0 +1,412 @@ +#![allow(dead_code, unused_imports)] + +use super::common::*; + +use serde_json::Value; +use serial_test::serial; + +const WORKSPACE_ID: &str = "ws_default"; + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn portable_yaml_v2_is_redacted_and_semantic_replay_is_a_noop() { + let registry = test_registry().await; + let storage_root = test_storage_root("operation-yaml-v2"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + let created = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload("http://127.0.0.1:9", "portable_v2")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap(); + + let yaml = client + .get(format!("{base_url}/operations/{operation_id}/export")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(yaml.contains("format_version: '2'") || yaml.contains("format_version: 2")); + let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + let operation = parsed["operation"].as_mapping().unwrap(); + for forbidden in ["id", "status", "created_at", "wizard_state", "samples"] { + assert!(!operation.contains_key(serde_yaml::Value::String(forbidden.to_owned()))); + } + + let replay = client + .post(format!("{base_url}/operations/import?mode=upsert")) + .header("content-type", "application/yaml") + .body(yaml.clone()) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(replay["operation_id"], operation_id); + assert_eq!(replay["version"], 1); + + let changed_yaml = yaml.replace( + "display_name: Create Lead", + "display_name: Portable Changed", + ); + assert_ne!(changed_yaml, yaml); + let missing = client + .post(format!("{base_url}/operations/import?mode=upsert")) + .header("content-type", "application/yaml") + .body(changed_yaml.clone()) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), reqwest::StatusCode::PRECONDITION_REQUIRED); + let missing = missing.json::().await.unwrap(); + assert_eq!(missing["error"]["code"], "operation_precondition_required"); + + let changed = client + .post(format!("{base_url}/operations/import?mode=upsert")) + .header("content-type", "application/yaml") + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, operation_id).await, + ) + .body(changed_yaml) + .send() + .await + .unwrap(); + assert_eq!(changed.status(), reqwest::StatusCode::OK); + let changed = changed.json::().await.unwrap(); + assert_eq!(changed["version"], 2); + + let current_yaml = client + .get(format!("{base_url}/operations/{operation_id}/export")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + let archived = client + .post(format!("{base_url}/operations/{operation_id}/archive")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, operation_id).await, + ) + .send() + .await + .unwrap(); + assert!(archived.status().is_success()); + let replay = client + .post(format!("{base_url}/operations/import?mode=upsert")) + .header("content-type", "application/yaml") + .body(current_yaml) + .send() + .await + .unwrap(); + assert_eq!(replay.status(), reqwest::StatusCode::CONFLICT); + assert!(replay.text().await.unwrap().contains("operation_archived")); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn hostile_and_oversized_yaml_fail_before_mutation_without_echoing_canary() { + let registry = test_registry().await; + let storage_root = test_storage_root("operation-yaml-hostile"); + let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; + let client = authorized_client(&base_url).await; + let canary = "yaml-secret-canary-never-reflect"; + let hostile = format!( + "format_version: '2'\nkind: operation\noperation:\n name: bad\n unknown_secret: {canary}\n" + ); + let response = client + .post(format!("{base_url}/operations/import")) + .header("content-type", "application/yaml") + .body(hostile) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + let body = response.text().await.unwrap(); + assert!(!body.contains(canary)); + assert!(body.contains("operation_yaml_invalid")); + let body: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(body["error"]["code"], "operation_yaml_invalid"); + + let oversized = "x".repeat(256 * 1024 + 1); + let response = client + .post(format!("{base_url}/operations/import")) + .header("content-type", "application/yaml") + .body(oversized) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE); + let count = registry + .list_operations(&crank_core::WorkspaceId::new(WORKSPACE_ID)) + .await + .unwrap() + .len(); + assert_eq!(count, 0); + + let alias = "format_version: '2'\nkind: operation\noperation: &shared\n name: alias\n"; + let response = client + .post(format!("{base_url}/operations/import")) + .header("content-type", "application/yaml") + .body(alias) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + assert!( + response + .text() + .await + .unwrap() + .contains("operation_yaml_unsupported") + ); + + let response = client + .post(format!("{base_url}/operations/import")) + .header("content-type", "application/yaml") + .body(vec![0xff, 0xfe, 0xfd]) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + assert!( + response + .text() + .await + .unwrap() + .contains("operation_yaml_invalid") + ); + + let mut credential_payload = serde_json::to_value(test_operation_payload( + "http://127.0.0.1:9", + "portable_credential_header", + )) + .unwrap(); + credential_payload["target"]["static_headers"]["X-Auth-Token"] = + Value::String("credential-canary".to_owned()); + let created = client + .post(format!("{base_url}/operations")) + .json(&credential_payload) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let credential_operation_id = created["operation_id"].as_str().unwrap(); + let response = client + .get(format!( + "{base_url}/operations/{credential_operation_id}/export" + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + assert!(!response.text().await.unwrap().contains("credential-canary")); + + let mut portable = serde_json::to_value(test_operation_payload( + "http://127.0.0.1:9", + "unknown_nested_yaml", + )) + .unwrap(); + portable.as_object_mut().unwrap().remove("wizard_state"); + portable["target"]["unknown_nested"] = Value::String(canary.to_owned()); + let document = serde_yaml::to_string(&serde_json::json!({ + "format_version": "2", + "kind": "operation", + "operation": portable + })) + .unwrap(); + let response = client + .post(format!("{base_url}/operations/import")) + .header("content-type", "application/yaml") + .body(document) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + assert!(!response.text().await.unwrap().contains(canary)); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn operation_mutations_require_identity_bound_etags_and_versions_are_stable() { + let registry = test_registry().await; + let storage_root = test_storage_root("operation-etag"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let first = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload("http://127.0.0.1:9", "etag_first")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let second = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload("http://127.0.0.1:9", "etag_second")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let first_id = first["operation_id"].as_str().unwrap(); + let second_id = second["operation_id"].as_str().unwrap(); + + let missing = client + .post(format!("{base_url}/operations/{first_id}/archive")) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), reqwest::StatusCode::PRECONDITION_REQUIRED); + let missing = missing.json::().await.unwrap(); + assert_eq!(missing["error"]["code"], "operation_precondition_required"); + + let first_etag = operation_etag(&client, &base_url, first_id).await; + let replay = client + .post(format!("{base_url}/operations/{second_id}/archive")) + .header(reqwest::header::IF_MATCH, first_etag) + .send() + .await + .unwrap(); + assert_eq!(replay.status(), reqwest::StatusCode::CONFLICT); + let replay = replay.json::().await.unwrap(); + assert_eq!(replay["error"]["code"], "operation_stale_version"); + + let v1 = client + .get(format!("{base_url}/operations/{first_id}/versions/1")) + .send() + .await + .unwrap(); + assert_eq!(v1.status(), reqwest::StatusCode::OK); + let v1_etag = v1.headers()[reqwest::header::ETAG] + .to_str() + .unwrap() + .to_owned(); + let mut update = + serde_json::to_value(test_operation_payload("http://127.0.0.1:9", "etag_first")).unwrap(); + update["display_name"] = Value::String("Changed Draft".to_owned()); + let updated = client + .patch(format!("{base_url}/operations/{first_id}")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, first_id).await, + ) + .json(&update) + .send() + .await + .unwrap(); + assert_eq!(updated.status(), reqwest::StatusCode::OK); + + let v1_after = client + .get(format!("{base_url}/operations/{first_id}/versions/1")) + .send() + .await + .unwrap(); + assert_eq!(v1_after.status(), reqwest::StatusCode::OK); + assert_eq!( + v1_after.headers()[reqwest::header::ETAG].to_str().unwrap(), + v1_etag + ); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn same_etag_concurrent_patches_have_exactly_one_winner() { + let registry = test_registry().await; + let storage_root = test_storage_root("operation-etag-race"); + let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; + let client = authorized_client(&base_url).await; + let created = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload("http://127.0.0.1:9", "etag_race")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap(); + let etag = operation_etag(&client, &base_url, operation_id).await; + let mut first = + serde_json::to_value(test_operation_payload("http://127.0.0.1:9", "etag_race")).unwrap(); + first["display_name"] = Value::String("first winner candidate".to_owned()); + let mut second = first.clone(); + second["display_name"] = Value::String("second winner candidate".to_owned()); + let url = format!("{base_url}/operations/{operation_id}"); + let first_request = client + .patch(&url) + .header(reqwest::header::IF_MATCH, &etag) + .json(&first) + .send(); + let second_request = client + .patch(&url) + .header(reqwest::header::IF_MATCH, &etag) + .json(&second) + .send(); + let (first_response, second_response) = tokio::join!(first_request, second_request); + let statuses = [ + first_response.unwrap().status(), + second_response.unwrap().status(), + ]; + assert_eq!( + statuses.iter().filter(|status| status.is_success()).count(), + 1 + ); + assert_eq!( + statuses + .iter() + .filter(|status| **status == reqwest::StatusCode::CONFLICT) + .count(), + 1 + ); + let summary = registry + .get_operation_summary( + &crank_core::WorkspaceId::new(WORKSPACE_ID), + &crank_core::OperationId::new(operation_id), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(summary.current_draft_version, 2); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn operation_rejects_unscoped_auth_profile_reference_before_save() { + let registry = test_registry().await; + let storage_root = test_storage_root("operation-auth-reference"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + let mut payload = serde_json::to_value(test_operation_payload( + "http://127.0.0.1:9", + "missing_auth_profile", + )) + .unwrap(); + payload["execution_config"]["auth_profile_ref"] = + Value::String("auth_foreign_or_missing".to_owned()); + let response = client + .post(format!("{base_url}/operations")) + .json(&payload) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + let body = response.text().await.unwrap(); + assert!(body.contains("operation_auth_profile_invalid")); + assert!(!body.contains("auth_foreign_or_missing")); +} diff --git a/apps/admin-api/tests/integration/operations_agents.rs b/apps/admin-api/tests/integration/operations_agents.rs index f132250..d0afc59 100644 --- a/apps/admin-api/tests/integration/operations_agents.rs +++ b/apps/admin-api/tests/integration/operations_agents.rs @@ -36,7 +36,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local"; const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_SESSION_SECRET: &str = "test-session-secret"; -const TEST_MASTER_KEY: &str = "test-master-key"; +const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000"; struct TestServer { base_url: String, @@ -123,15 +123,6 @@ async fn creates_publishes_and_tests_rest_operation() { .json::() .await .unwrap(); - let published = client - .post(format!("{base_url}/operations/{operation_id}/publish")) - .json(&json!({ "version": 1 })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); let test_run_response = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .header("x-request-id", "req_admin_test_run") @@ -151,6 +142,19 @@ async fn creates_publishes_and_tests_rest_operation() { "0af7651916cd43dd8448eb211c80319c" ); let test_run = test_run_response.json::().await.unwrap(); + let published = client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &operation_id).await, + ) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); assert_eq!(listed["items"][0]["name"], "crm_create_lead"); assert_eq!( @@ -160,20 +164,24 @@ async fn creates_publishes_and_tests_rest_operation() { assert_eq!(listed["items"][0]["target_action"], "POST"); assert_eq!(published["published_version"], 1); assert_eq!(test_run["ok"], true); - assert_eq!( - test_run["request_preview"]["body"]["email"], - "user@example.com" - ); + assert_eq!(test_run["request_preview"]["body_configured"], true); + let safe_preview = test_run["request_preview"].to_string(); + assert!(!safe_preview.contains("user@example.com")); assert_eq!(test_run["response_preview"]["id"], "lead_123"); let logs = registry .list_invocation_logs(crank_registry::ListInvocationLogsQuery { workspace_id: &WorkspaceId::new(DEFAULT_WORKSPACE_ID), level: None, + status: None, + outcome_group: None, search_text: None, source: Some(crank_core::InvocationSource::AdminTestRun), operation_id: Some(&crank_core::OperationId::new(&operation_id)), agent_id: None, created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 10, }) .await @@ -267,6 +275,10 @@ async fn updates_archives_and_deletes_operation() { let updated = client .patch(format!("{base_url}/operations/{operation_id}")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &operation_id).await, + ) .json(&json!({ "display_name": "Create Lead Updated", "category": "marketing", @@ -350,6 +362,10 @@ async fn updates_archives_and_deletes_operation() { let archived = client .post(format!("{base_url}/operations/{operation_id}/archive")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &operation_id).await, + ) .send() .await .unwrap() @@ -360,29 +376,29 @@ async fn updates_archives_and_deletes_operation() { let deleted = client .delete(format!("{base_url}/operations/{operation_id}")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &operation_id).await, + ) + .send() + .await + .unwrap(); + assert_eq!(deleted.status(), reqwest::StatusCode::CONFLICT); + let deleted = deleted.json::().await.unwrap(); + assert_eq!( + deleted["error"]["context"]["error_code"], + "operation_delete_forbidden" + ); + + let retained = client + .get(format!("{base_url}/operations/{operation_id}")) .send() .await .unwrap() .json::() .await .unwrap(); - assert_eq!(deleted["operation_id"], operation_id); - - let missing = client - .get(format!("{base_url}/operations/{operation_id}")) - .send() - .await - .unwrap(); - let missing_status = missing.status(); - let missing = missing.json::().await.unwrap(); - assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND); - assert_eq!(missing["error"]["code"], "not_found"); - assert_eq!( - missing["error"]["context"], - json!({ - "operation_id": operation_id - }) - ); + assert_eq!(retained["status"], "archived"); } #[tokio::test(flavor = "multi_thread")] @@ -408,6 +424,10 @@ async fn creates_binds_and_publishes_agent() { client .post(format!("{base_url}/operations/{operation_id}/publish")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &operation_id).await, + ) .json(&json!({ "version": 1 })) .send() .await @@ -432,6 +452,10 @@ async fn creates_binds_and_publishes_agent() { let bindings = client .post(format!("{base_url}/agents/{agent_id}/bindings")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .json(&json!([ { "operation_id": operation_id, @@ -449,6 +473,10 @@ async fn creates_binds_and_publishes_agent() { .unwrap(); let published = client .post(format!("{base_url}/agents/{agent_id}/publish")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .json(&json!({ "version": 1 })) .send() .await @@ -489,6 +517,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() { assert_success_json( client .post(format!("{base_url}/operations/{operation_id}/publish")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &operation_id).await, + ) .json(&json!({"version": 1})) .send() .await @@ -535,6 +567,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() { let saved = assert_success_json( client .post(format!("{base_url}/agents/{agent_id}/bindings")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .json(&catalog) .send() .await @@ -565,6 +601,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() { let published = assert_success_json( client .post(format!("{base_url}/agents/{agent_id}/publish")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .json(&json!({"version": 1})) .send() .await @@ -576,7 +616,7 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() { #[tokio::test(flavor = "multi_thread")] #[serial] -async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() { +async fn agent_binding_rejects_draft_operation_before_publish() { let registry = test_registry().await; let storage_root = test_storage_root("agent_publish_filters_drafts"); let upstream_base_url = spawn_upstream_server().await; @@ -604,6 +644,10 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() { .post(format!( "{base_url}/operations/{published_operation_id}/publish" )) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &published_operation_id).await, + ) .json(&json!({ "version": 1 })) .send() .await @@ -642,9 +686,47 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() { .await; let agent_id = agent["agent_id"].as_str().unwrap().to_owned(); + let rejected = client + .post(format!("{base_url}/agents/{agent_id}/bindings")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) + .json(&json!([ + { + "operation_id": published_operation_id, + "operation_version": 1, + "tool_name": "crm_published_tool", + "tool_title": "Published Tool", + "tool_description_override": null, + "enabled": true + }, + { + "operation_id": draft_operation_id, + "operation_version": 1, + "tool_name": "crm_draft_tool", + "tool_title": "Draft Tool", + "tool_description_override": null, + "enabled": true + } + ])) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + let rejected_body = rejected.json::().await.unwrap(); + assert_eq!( + rejected_body["error"]["code"], + "agent_binding_not_published" + ); + assert_success_json( client .post(format!("{base_url}/agents/{agent_id}/bindings")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .json(&json!([ { "operation_id": published_operation_id, @@ -653,14 +735,6 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() { "tool_title": "Published Tool", "tool_description_override": null, "enabled": true - }, - { - "operation_id": draft_operation_id, - "operation_version": 1, - "tool_name": "crm_draft_tool", - "tool_title": "Draft Tool", - "tool_description_override": null, - "enabled": true } ])) .send() @@ -672,6 +746,10 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() { let published = assert_success_json( client .post(format!("{base_url}/agents/{agent_id}/publish")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .json(&json!({ "version": 1 })) .send() .await @@ -694,24 +772,15 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() { .unwrap(), ) .await; - let draft_version = assert_success_json( - client - .get(format!("{base_url}/agents/{agent_id}/versions/2")) - .send() - .await - .unwrap(), - ) - .await; assert_eq!(published["published_version"], 1); - assert_eq!(agent_detail["current_draft_version"], 2); + assert_eq!(agent_detail["current_draft_version"], 1); assert_eq!(agent_detail["latest_published_version"], 1); assert_eq!(published_version["bindings"].as_array().unwrap().len(), 1); assert_eq!( published_version["bindings"][0]["operation_id"], published_operation_id ); - assert_eq!(draft_version["bindings"].as_array().unwrap().len(), 2); } #[tokio::test(flavor = "multi_thread")] @@ -737,6 +806,10 @@ async fn updates_lists_and_deletes_agent() { client .post(format!("{base_url}/operations/{operation_id}/publish")) + .header( + reqwest::header::IF_MATCH, + operation_etag(&client, &base_url, &operation_id).await, + ) .json(&json!({ "version": 1 })) .send() .await @@ -761,6 +834,10 @@ async fn updates_lists_and_deletes_agent() { client .post(format!("{base_url}/agents/{agent_id}/bindings")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .json(&json!([ { "operation_id": operation_id, @@ -777,6 +854,10 @@ async fn updates_lists_and_deletes_agent() { client .post(format!("{base_url}/agents/{agent_id}/publish")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .json(&json!({ "version": 1 })) .send() .await @@ -800,6 +881,10 @@ async fn updates_lists_and_deletes_agent() { let updated = client .patch(format!("{base_url}/agents/{agent_id}")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .json(&json!({ "slug": "support-escalation", "display_name": "Support Escalation", @@ -807,11 +892,17 @@ async fn updates_lists_and_deletes_agent() { })) .send() .await - .unwrap() - .json::() - .await .unwrap(); - assert_eq!(updated["agent_id"], agent_id); + assert_eq!( + updated.status(), + reqwest::StatusCode::CONFLICT, + "published Agent summary/slug must not mutate published MCP endpoint identity" + ); + let updated = updated.json::().await.unwrap(); + assert_eq!( + updated["error"]["context"]["error_code"], + "agent_published_summary_immutable" + ); let detail = client .get(format!("{base_url}/agents/{agent_id}")) @@ -821,148 +912,35 @@ async fn updates_lists_and_deletes_agent() { .json::() .await .unwrap(); - assert_eq!(detail["slug"], "support-escalation"); - assert_eq!(detail["display_name"], "Support Escalation"); + assert_eq!(detail["slug"], "support-team"); + assert_eq!(detail["display_name"], "Support Team"); assert_eq!(detail["operation_count"], 1); - assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-escalation"); + assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-team"); - let deleted = client + let delete_response = client .delete(format!("{base_url}/agents/{agent_id}")) + .header( + reqwest::header::IF_MATCH, + agent_etag(&client, &base_url, &agent_id).await, + ) .send() .await - .unwrap() - .json::() - .await .unwrap(); - assert_eq!(deleted["agent_id"], agent_id); + assert_eq!( + delete_response.status(), + reqwest::StatusCode::CONFLICT, + "published Agent must not be hard-deleted because immutable versions/history must remain" + ); + let delete_body = delete_response.json::().await.unwrap(); + assert_eq!( + delete_body["error"]["context"]["error_code"], + "agent_delete_forbidden" + ); - let missing = client + let still_present = client .get(format!("{base_url}/agents/{agent_id}")) .send() .await .unwrap(); - let missing_status = missing.status(); - let missing = missing.json::().await.unwrap(); - assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND); - assert_eq!(missing["error"]["code"], "not_found"); - assert_eq!( - missing["error"]["context"], - json!({ - "agent_id": agent_id - }) - ); -} - -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn unpublishes_and_archives_agent() { - let registry = test_registry().await; - let storage_root = test_storage_root("agent_statuses"); - let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let operation = assert_success_json( - client - .post(format!("{base_url}/operations")) - .json(&test_operation_payload( - &upstream_base_url, - "crm_create_lead_agent_status", - )) - .send() - .await - .unwrap(), - ) - .await; - let operation_id = operation["operation_id"].as_str().unwrap().to_owned(); - - client - .post(format!("{base_url}/operations/{operation_id}/publish")) - .json(&json!({ "version": 1 })) - .send() - .await - .unwrap(); - - let created = assert_success_json( - client - .post(format!("{base_url}/agents")) - .json(&json!({ - "slug": "sales-routing", - "display_name": "Sales Routing", - "description": "Routing agent", - "instructions": {}, - "tool_selection_policy": {} - })) - .send() - .await - .unwrap(), - ) - .await; - let agent_id = created["agent_id"].as_str().unwrap().to_owned(); - - client - .post(format!("{base_url}/agents/{agent_id}/bindings")) - .json(&json!([ - { - "operation_id": operation_id, - "operation_version": 1, - "tool_name": "crm_create_lead_agent_status", - "tool_title": "Create Lead", - "tool_description_override": null, - "enabled": true - } - ])) - .send() - .await - .unwrap(); - - client - .post(format!("{base_url}/agents/{agent_id}/publish")) - .json(&json!({ "version": 1 })) - .send() - .await - .unwrap(); - - let unpublished = assert_success_json( - client - .post(format!("{base_url}/agents/{agent_id}/unpublish")) - .json(&json!({})) - .send() - .await - .unwrap(), - ) - .await; - assert_eq!(unpublished["agent_id"], agent_id); - - let draft_detail = assert_success_json( - client - .get(format!("{base_url}/agents/{agent_id}")) - .send() - .await - .unwrap(), - ) - .await; - assert_eq!(draft_detail["status"], "draft"); - assert_eq!(draft_detail["latest_published_version"], 1); - - let archived = assert_success_json( - client - .post(format!("{base_url}/agents/{agent_id}/archive")) - .json(&json!({})) - .send() - .await - .unwrap(), - ) - .await; - assert_eq!(archived["agent_id"], agent_id); - - let archived_detail = assert_success_json( - client - .get(format!("{base_url}/agents/{agent_id}")) - .send() - .await - .unwrap(), - ) - .await; - assert_eq!(archived_detail["status"], "archived"); + assert_eq!(still_present.status(), reqwest::StatusCode::OK); } diff --git a/apps/admin-api/tests/integration/secrets_import_auth.rs b/apps/admin-api/tests/integration/secrets_import_auth.rs index dd747e5..042d96a 100644 --- a/apps/admin-api/tests/integration/secrets_import_auth.rs +++ b/apps/admin-api/tests/integration/secrets_import_auth.rs @@ -36,7 +36,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local"; const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_SESSION_SECRET: &str = "test-session-secret"; -const TEST_MASTER_KEY: &str = "test-master-key"; +const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000"; struct TestServer { base_url: String, @@ -168,7 +168,7 @@ async fn manages_auth_profiles_and_yaml_upsert() { secret_id ); assert_eq!(imported["operation_id"], operation_id); - assert_eq!(imported["version"], 2); + assert_eq!(imported["version"], 1); assert_eq!(imported["import_mode"], "upsert"); } @@ -379,7 +379,7 @@ async fn rejects_deleting_secret_referenced_by_auth_profile() { let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::CONFLICT); - assert_eq!(body["error"]["code"], "conflict"); + assert_eq!(body["error"]["code"], "secret_referenced_by_auth_profile"); assert_eq!( body["error"]["message"], format!("secret {secret_id} is referenced by auth profile {auth_profile_id}") diff --git a/apps/admin-api/tests/migration_command.rs b/apps/admin-api/tests/migration_command.rs index e6fd9a3..3f651b1 100644 --- a/apps/admin-api/tests/migration_command.rs +++ b/apps/admin-api/tests/migration_command.rs @@ -1,5 +1,14 @@ use std::process::{Command, Output}; +use crank_community_auth::{hash_password, verify_password}; +use crank_core::{Secret, SecretId, SecretKind, SecretStatus, UserSessionId, WorkspaceId}; +use crank_registry::{ + CreateSecretRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry, +}; +use crank_runtime::SecretCrypto; +use serde_json::json; +use time::{Duration as TimeDuration, OffsetDateTime, format_description::well_known::Rfc3339}; + fn command(arguments: &[&str], database_url: Option<&str>) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_crank-migrate")); command.args(arguments); @@ -16,6 +25,10 @@ fn command(arguments: &[&str], database_url: Option<&str>) -> Output { command.output().expect("migration command must run") } +fn timestamp(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &Rfc3339).unwrap() +} + #[test] fn plan_is_deterministic_and_committed_contract_is_current() { let first = command(&["plan"], None); @@ -27,7 +40,7 @@ fn plan_is_deterministic_and_committed_contract_is_current() { ); assert_eq!(first.stdout, second.stdout); let plan: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); - assert_eq!(plan["sequence"].as_array().unwrap().len(), 3); + assert_eq!(plan["sequence"].as_array().unwrap().len(), 11); let checked = command(&["plan", "--check"], None); assert!( @@ -69,7 +82,147 @@ async fn database_only_config_can_apply_and_preflight_a_fresh_schema() { ); let result: serde_json::Value = serde_json::from_slice(&preflight.stdout).unwrap(); assert_eq!(result["status"], "current"); - assert_eq!(result["version"], 3); + assert_eq!(result["version"], 11); +} + +#[tokio::test] +async fn admin_auth_bootstrap_create_outputs_bootstrap_secret_without_service_secrets() { + let database_url = + crank_test_support::postgres_schema_url("test_migration_admin_auth_bootstrap").await; + assert!(command(&["apply"], Some(&database_url)).status.success()); + + let created = command( + &[ + "admin-auth", + "bootstrap-create", + "--email", + "operator@example.local", + "--display-name", + "Local Operator", + "--ttl-seconds", + "900", + ], + Some(&database_url), + ); + assert!( + created.status.success(), + "{}", + String::from_utf8_lossy(&created.stderr) + ); + let payload: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + assert_eq!(payload["status"], "bootstrap_created"); + assert!( + payload["contract_id"] + .as_str() + .unwrap() + .starts_with("boot_") + ); + assert!(payload["bootstrap_token"].as_str().unwrap().len() >= 32); + let stdout = String::from_utf8(created.stdout).unwrap(); + assert!(!stdout.contains("CRANK_SESSION_SECRET")); + assert!(!stdout.contains("CRANK_PASSWORD_PEPPER")); + + let duplicate = command( + &[ + "admin-auth", + "bootstrap-create", + "--email", + "operator@example.local", + ], + Some(&database_url), + ); + assert!(!duplicate.status.success()); + let diagnostic: serde_json::Value = serde_json::from_slice(&duplicate.stderr).unwrap(); + assert_eq!(diagnostic["code"], "admin_bootstrap_unavailable"); +} + +#[tokio::test] +async fn admin_auth_recovery_requires_master_key_and_revokes_sessions_without_secret_output() { + let database_url = + crank_test_support::postgres_schema_url("test_migration_admin_auth_recovery").await; + assert!(command(&["apply"], Some(&database_url)).status.success()); + let registry = PostgresRegistry::connect(&database_url).await.unwrap(); + let now = timestamp("2026-08-21T00:00:00Z"); + let master_key = "recovery-master-key-CANARY_SECRET_VALUE-000000000"; + let crypto = SecretCrypto::new(master_key).unwrap(); + registry + .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { + epoch: crypto.master_key_epoch(), + fingerprint: crypto.master_key_fingerprint(), + cipher_contract: MASTER_KEY_CIPHER_CONTRACT, + observed_at: &now, + }) + .await + .unwrap(); + + let pepper = "recovery-pepper-CANARY_SECRET_VALUE"; + let old_password = "old-recovery-password-CANARY_SECRET_VALUE"; + let old_hash = hash_password(old_password, pepper).unwrap(); + let user_id = registry + .upsert_bootstrap_user("recover@example.local", "Recover Operator", &old_hash) + .await + .unwrap(); + let session_id = UserSessionId::new("session_recovery_cli"); + registry + .create_user_session( + &session_id, + &user_id, + Some(&WorkspaceId::new("ws_default")), + "session-secret-hash", + None, + &(OffsetDateTime::now_utc() + TimeDuration::hours(1)), + ) + .await + .unwrap(); + + let key_dir = + std::env::temp_dir().join(format!("crank-admin-auth-recovery-{}", std::process::id())); + std::fs::create_dir_all(&key_dir).unwrap(); + let password_path = key_dir.join("new-password.txt"); + let pepper_path = key_dir.join("pepper.txt"); + let master_key_path = key_dir.join("master.key"); + let new_password = "new-recovery-password-CANARY_SECRET_VALUE"; + std::fs::write(&password_path, format!("{new_password}\n")).unwrap(); + std::fs::write(&pepper_path, format!("{pepper}\n")).unwrap(); + std::fs::write(&master_key_path, format!("{master_key}\n")).unwrap(); + + let recovered = command( + &[ + "admin-auth", + "recover", + "--email", + "recover@example.local", + "--password-file", + password_path.to_str().unwrap(), + "--password-pepper-file", + pepper_path.to_str().unwrap(), + "--master-key-file", + master_key_path.to_str().unwrap(), + ], + Some(&database_url), + ); + assert!( + recovered.status.success(), + "{}", + String::from_utf8_lossy(&recovered.stderr) + ); + let stdout = String::from_utf8(recovered.stdout).unwrap(); + assert!(stdout.contains("\"status\":\"admin_recovered\"")); + assert!(!stdout.contains("CANARY_SECRET_VALUE")); + assert!( + registry + .get_user_session(&session_id, "session-secret-hash") + .await + .unwrap() + .is_none() + ); + let user = registry + .get_auth_user_by_email("recover@example.local") + .await + .unwrap() + .unwrap(); + assert!(verify_password(new_password, pepper, &user.password_hash)); + assert!(!verify_password(old_password, pepper, &user.password_hash)); } #[tokio::test] @@ -88,3 +241,197 @@ async fn migration_error_json_preserves_affected_version() { assert_eq!(diagnostic["code"], "checksum_mismatch"); assert_eq!(diagnostic["version"], 2); } + +#[tokio::test] +async fn master_key_rotation_command_is_resumable_and_redacted() { + let database_url = + crank_test_support::postgres_schema_url("test_master_key_rotation_cli").await; + assert!(command(&["apply"], Some(&database_url)).status.success()); + let registry = PostgresRegistry::connect(&database_url).await.unwrap(); + let now = timestamp("2026-08-21T00:00:00Z"); + let current_key = "current-master-key-cli-canary-0000000000000000"; + let target_key = "target-master-key-cli-canary-00000000000000000"; + let current_crypto = SecretCrypto::new(current_key).unwrap(); + registry + .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { + epoch: 1, + fingerprint: current_crypto.master_key_fingerprint(), + cipher_contract: MASTER_KEY_CIPHER_CONTRACT, + observed_at: &now, + }) + .await + .unwrap(); + insert_secret(®istry, "cli_secret_a", ¤t_crypto, &now).await; + insert_secret(®istry, "cli_secret_b", ¤t_crypto, &now).await; + + let key_dir = + std::env::temp_dir().join(format!("crank-master-key-rotation-{}", std::process::id())); + std::fs::create_dir_all(&key_dir).unwrap(); + let current_path = key_dir.join("current.key"); + let target_path = key_dir.join("target.key"); + std::fs::write(¤t_path, current_key).unwrap(); + std::fs::write(&target_path, target_key).unwrap(); + let current_path = current_path.to_string_lossy().to_string(); + let target_path = target_path.to_string_lossy().to_string(); + + let preflight = command( + &[ + "master-key", + "preflight", + "--current-key-file", + ¤t_path, + "--target-key-file", + &target_path, + "--backup-ref", + "offline-backup-ref", + ], + Some(&database_url), + ); + assert!( + preflight.status.success(), + "{}", + String::from_utf8_lossy(&preflight.stderr) + ); + assert_redacted(&preflight, current_key, target_key); + let preflight_json: serde_json::Value = serde_json::from_slice(&preflight.stdout).unwrap(); + assert_eq!(preflight_json["status"], "preflight_ok"); + assert_eq!(preflight_json["affected_secret_versions"], 2); + assert!( + registry + .master_key_rotation_status() + .await + .unwrap() + .rotations + .is_empty() + ); + + let partial = command( + &[ + "master-key", + "rotate", + "--current-key-file", + ¤t_path, + "--target-key-file", + &target_path, + "--backup-ref", + "offline-backup-ref", + "--max-versions", + "1", + ], + Some(&database_url), + ); + assert!( + partial.status.success(), + "{}", + String::from_utf8_lossy(&partial.stderr) + ); + assert_redacted(&partial, current_key, target_key); + let partial_json: serde_json::Value = serde_json::from_slice(&partial.stdout).unwrap(); + assert_eq!(partial_json["status"], "running"); + assert_eq!(partial_json["processed_secret_versions"], 1); + + let resumed = command( + &[ + "master-key", + "rotate", + "--current-key-file", + ¤t_path, + "--target-key-file", + &target_path, + "--backup-ref", + "offline-backup-ref", + ], + Some(&database_url), + ); + assert!( + resumed.status.success(), + "{}", + String::from_utf8_lossy(&resumed.stderr) + ); + let resumed_json: serde_json::Value = serde_json::from_slice(&resumed.stdout).unwrap(); + assert_eq!(resumed_json["status"], "verifying"); + assert_eq!(resumed_json["processed_secret_versions"], 2); + + let verified = command( + &["master-key", "verify", "--target-key-file", &target_path], + Some(&database_url), + ); + assert!( + verified.status.success(), + "{}", + String::from_utf8_lossy(&verified.stderr) + ); + let verified_json: serde_json::Value = serde_json::from_slice(&verified.stdout).unwrap(); + assert_eq!(verified_json["status"], "verified"); + + let promoted = command( + &["master-key", "promote", "--target-key-file", &target_path], + Some(&database_url), + ); + assert!( + promoted.status.success(), + "{}", + String::from_utf8_lossy(&promoted.stderr) + ); + assert_redacted(&promoted, current_key, target_key); + let promoted_json: serde_json::Value = serde_json::from_slice(&promoted.stdout).unwrap(); + assert_eq!(promoted_json["status"], "promoted"); + assert_eq!(promoted_json["active_epoch"], 2); + + let old_key_rejected = command( + &[ + "master-key", + "preflight", + "--current-key-file", + ¤t_path, + "--target-key-file", + &target_path, + ], + Some(&database_url), + ); + assert!(!old_key_rejected.status.success()); + assert_redacted(&old_key_rejected, current_key, target_key); + let diagnostic: serde_json::Value = serde_json::from_slice(&old_key_rejected.stderr).unwrap(); + assert_eq!(diagnostic["code"], "master_key_identity_mismatch"); +} + +async fn insert_secret( + registry: &PostgresRegistry, + id: &str, + crypto: &SecretCrypto, + now: &OffsetDateTime, +) { + let ciphertext = crypto + .encrypt(&json!({ "token": format!("{id}_value") })) + .unwrap(); + let secret = Secret { + id: SecretId::new(id), + workspace_id: WorkspaceId::new("ws_default"), + name: id.to_owned(), + kind: SecretKind::Token, + status: SecretStatus::Active, + current_version: 1, + created_at: *now, + updated_at: *now, + last_used_at: None, + }; + registry + .create_secret(CreateSecretRequest { + secret: &secret, + ciphertext: &ciphertext, + key_version: crypto.key_version(), + master_key_epoch: crypto.master_key_epoch(), + created_by: None, + }) + .await + .unwrap(); +} + +fn assert_redacted(output: &Output, current_key: &str, target_key: &str) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stdout.contains(current_key)); + assert!(!stdout.contains(target_key)); + assert!(!stderr.contains(current_key)); + assert!(!stderr.contains(target_key)); +} diff --git a/apps/admin-api/tests/unit.rs b/apps/admin-api/tests/unit.rs index 1b31879..d913c32 100644 --- a/apps/admin-api/tests/unit.rs +++ b/apps/admin-api/tests/unit.rs @@ -1,2 +1,4 @@ #[path = "unit/error.rs"] mod error; +#[path = "unit/execution_parity.rs"] +mod execution_parity; diff --git a/apps/admin-api/tests/unit/error.rs b/apps/admin-api/tests/unit/error.rs index bfe8a51..a79ece2 100644 --- a/apps/admin-api/tests/unit/error.rs +++ b/apps/admin-api/tests/unit/error.rs @@ -1,6 +1,8 @@ -use admin_api::error::{runtime_error_context, runtime_test_failure}; +use admin_api::error::{ + execution_test_failure, execution_test_failure_localized, runtime_test_failure, +}; +use crank_core::{CorrelationContext, ExecutionErrorCode, ExecutionFailure, ExecutionLocale}; use crank_runtime::RuntimeError; -use serde_json::json; #[test] fn runtime_test_failure_includes_structured_context() { @@ -9,23 +11,23 @@ fn runtime_test_failure_includes_structured_context() { reason: "must be an object".to_owned(), }); - assert_eq!(payload["code"], "runtime_request_error"); - assert_eq!( - payload["context"], - json!({ - "field": "request.headers" - }) - ); + assert_eq!(payload["code"], "prepared_request_invalid"); + assert_eq!(payload["stage"], "request_preparation"); + assert_eq!(payload["retryability"], "never"); + assert_eq!(payload["outcome_certainty"], "certain"); + assert!(payload.get("context").is_none()); } #[test] fn runtime_error_context_does_not_expose_secret_crypto_details() { - let context = runtime_error_context(&RuntimeError::SecretCrypto { + let canary = "story18-secret-crypto-canary"; + let payload = runtime_test_failure(&RuntimeError::SecretCrypto { operation: "decode secret envelope", - details: "bad base64".to_owned(), + details: canary.to_owned(), }); - assert_eq!(context, None); + assert_eq!(payload["code"], "secret_invalid"); + assert!(!payload.to_string().contains(canary)); } #[test] @@ -35,12 +37,45 @@ fn runtime_test_failure_includes_runtime_overload_context() { limit: 16, }); - assert_eq!(payload["code"], "runtime_overloaded"); - assert_eq!( - payload["context"], - json!({ - "kind": "window", - "limit": 16 - }) - ); + assert_eq!(payload["code"], "execution_overloaded"); + assert_eq!(payload["stage"], "admission"); + assert_eq!(payload["retryability"], "after_delay"); + assert!(payload.get("context").is_none()); +} + +#[test] +fn every_execution_code_projects_the_canonical_semantics_without_raw_context() { + let canary = "story18-admin-projection-canary"; + for code in ExecutionErrorCode::ALL { + let failure = ExecutionFailure::new(code, CorrelationContext::generate()); + let payload = execution_test_failure(&failure); + assert_eq!(payload["code"], code.as_str()); + assert_eq!(payload["stage"], code.stage().as_str()); + assert_eq!(payload["retryability"], code.retryability().as_str()); + assert_eq!( + payload["outcome_certainty"], + code.outcome_certainty().as_str() + ); + assert!(!payload["message"].as_str().unwrap_or_default().is_empty()); + assert!(!payload.to_string().contains(canary)); + } +} + +#[test] +fn confirmation_challenge_is_bounded_and_localized_at_the_admin_boundary() { + let failure = ExecutionFailure::new( + ExecutionErrorCode::ConfirmationRequired, + CorrelationContext::generate(), + ) + .try_with_confirmation("confirmation-capability", 30_000) + .unwrap(); + let payload = execution_test_failure_localized(&failure, ExecutionLocale::Ru); + + assert_eq!(payload["message"], "Операция требует подтверждения."); + assert_eq!( + payload["context"]["confirmation_token"], + "confirmation-capability" + ); + assert_eq!(payload["context"]["expires_in_ms"], 30_000); + assert!(!format!("{failure:?}").contains("confirmation-capability")); } diff --git a/apps/admin-api/tests/unit/execution_parity.rs b/apps/admin-api/tests/unit/execution_parity.rs new file mode 100644 index 0000000..25c4656 --- /dev/null +++ b/apps/admin-api/tests/unit/execution_parity.rs @@ -0,0 +1,50 @@ +use admin_api::error::execution_test_failure_localized; +use crank_community_mcp::tool_error::tool_error_contract_from_failure; +use crank_core::{ + CorrelationContext, ExecutionErrorCode, ExecutionFailure, ExecutionLocale, Retryability, +}; + +#[test] +fn admin_and_mcp_project_every_execution_failure_with_identical_semantics() { + for locale in [ExecutionLocale::En, ExecutionLocale::Ru] { + for code in ExecutionErrorCode::ALL { + let failure = ExecutionFailure::new(code, CorrelationContext::generate()); + let admin = execution_test_failure_localized(&failure, locale); + let mcp = tool_error_contract_from_failure(&failure, locale); + + assert_eq!(admin["code"], mcp.error_code); + assert_eq!(admin["stage"], mcp.stage); + assert_eq!(admin["retryability"], mcp.retryability); + assert_eq!(admin["outcome_certainty"], mcp.outcome_certainty); + assert_eq!(admin["message"], mcp.message); + assert_eq!( + mcp.recoverable, + matches!( + code.retryability(), + Retryability::Safe + | Retryability::AfterDelay + | Retryability::RequiresConfirmation + ) + ); + } + } +} + +#[test] +fn admin_and_mcp_preserve_dispatch_uncertainty_without_changing_the_code() { + for locale in [ExecutionLocale::En, ExecutionLocale::Ru] { + let failure = ExecutionFailure::new( + ExecutionErrorCode::UpstreamTransportError, + CorrelationContext::generate(), + ) + .with_dispatch_uncertainty(); + let admin = execution_test_failure_localized(&failure, locale); + let mcp = tool_error_contract_from_failure(&failure, locale); + + assert_eq!(admin["retryability"], "manual_reconcile"); + assert_eq!(admin["outcome_certainty"], "outcome_unknown"); + assert_eq!(admin["retryability"], mcp.retryability); + assert_eq!(admin["outcome_certainty"], mcp.outcome_certainty); + assert!(!mcp.recoverable); + } +} diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 366b3a4..f80b913 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -13,7 +13,9 @@ use crank_observability::{ CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle, OtlpTraceConfig, RedactionLimits, SentryConfig, ServiceIdentity, capture_critical_error, }; -use crank_registry::{PostgresPoolConfig, PostgresRegistry}; +use crank_registry::{ + MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresPoolConfig, PostgresRegistry, +}; use crank_runtime::{ RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores, RuntimeLimits, SecretCrypto, @@ -62,6 +64,36 @@ fn safe_startup_diagnostic(error: &(dyn std::error::Error + 'static)) -> String }) .to_string(); } + if let Some(crank_registry::RegistryError::MasterKeyIdentityMismatch { epoch }) = + cause.downcast_ref::() + { + return serde_json::json!({ + "status": "error", + "code": "master_key_identity_mismatch", + "stage": "startup.master_key_identity", + "version": epoch, + "recovery": "configure_same_master_key", + }) + .to_string(); + } + if cause + .downcast_ref::() + .is_some_and(|error| { + matches!( + error, + crank_registry::RegistryError::InvalidMasterKeyIdentity + ) + }) + { + return serde_json::json!({ + "status": "error", + "code": "master_key_identity_invalid", + "stage": "startup.master_key_identity", + "version": null, + "recovery": "contact_operator", + }) + .to_string(); + } current = cause.source(); } serde_json::json!({ @@ -145,10 +177,13 @@ async fn run( spawn_postgres_pool_metrics(registry.pool().clone()); } let session_store = PostgresTransportSessionStore::from_pool(registry.pool().clone()).await?; - let secret_crypto = SecretCrypto::new(config.runtime.master_key.expose_secret())?; - let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new( + let secret_crypto = + verified_startup_secret_crypto(®istry, config.runtime.master_key.expose_secret()) + .await?; + let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new_with_limits( config.runtime.outbound.allowed_hosts.clone(), config.runtime.outbound.denied_hosts.clone(), + config.runtime.outbound.max_request_bytes, config.runtime.outbound.max_response_bytes, )?; let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy) @@ -275,9 +310,10 @@ fn preflight_config(config: &McpProcessConfig) -> Result<(), crank_config::Confi .map_err(|_| invalid("mcp.rate_limit"))?; SecretCrypto::new(config.runtime.master_key.expose_secret()) .map_err(|_| invalid("runtime.master_key"))?; - crank_runtime::OutboundHttpPolicy::try_new( + crank_runtime::OutboundHttpPolicy::try_new_with_limits( config.runtime.outbound.allowed_hosts.clone(), config.runtime.outbound.denied_hosts.clone(), + config.runtime.outbound.max_request_bytes, config.runtime.outbound.max_response_bytes, ) .map_err(|_| invalid("runtime.outbound"))?; @@ -326,6 +362,53 @@ fn preflight_config(config: &McpProcessConfig) -> Result<(), crank_config::Confi Ok(()) } +async fn verified_startup_secret_crypto( + registry: &PostgresRegistry, + master_key: &str, +) -> Result> { + let active = registry.active_master_key_identity().await?; + let secret_crypto = if let Some(identity) = active { + SecretCrypto::with_epoch(master_key, identity.epoch)? + } else { + let crypto = SecretCrypto::new(master_key)?; + let mut after_secret_id: Option = None; + let mut after_version: Option = None; + loop { + let versions = registry + .list_secret_versions_for_master_key_epoch_page( + 1, + after_secret_id.as_deref(), + after_version, + 1_000, + ) + .await?; + if versions.is_empty() { + break; + } + for version in versions { + after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned()); + after_version = Some(version.secret_version.version); + crypto.decrypt_for_epoch( + &version.secret_version.key_version, + version.master_key_epoch, + &version.secret_version.ciphertext, + )?; + } + } + crypto + }; + let master_key_observed_at = time::OffsetDateTime::now_utc(); + registry + .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { + epoch: secret_crypto.master_key_epoch(), + fingerprint: secret_crypto.master_key_fingerprint(), + cipher_contract: MASTER_KEY_CIPHER_CONTRACT, + observed_at: &master_key_observed_at, + }) + .await?; + Ok(secret_crypto) +} + fn postgres_pool_config( config: &DatabaseSettings, ) -> Result { diff --git a/apps/mcp-server/tests/config_startup.rs b/apps/mcp-server/tests/config_startup.rs index 7be9858..0dc77b4 100644 --- a/apps/mcp-server/tests/config_startup.rs +++ b/apps/mcp-server/tests/config_startup.rs @@ -1,11 +1,21 @@ use std::process::Command; +use crank_registry::{ + MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, MigrationAuthority, PostgresRegistry, +}; +use crank_runtime::SecretCrypto; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000"; +const REGISTERED_MASTER_KEY: &str = "registered-master-key-CANARY_SECRET_VALUE-00000000"; +const WRONG_MASTER_KEY: &str = "wrong-master-key-CANARY_SECRET_VALUE-0000000000000"; + fn run_with(entries: &[(&str, &str)]) -> String { let mut command = Command::new(env!("CARGO_BIN_EXE_mcp-server")); for field in crank_config::field_registry() { command.env_remove(field.env_name); } - command.env("CRANK_MASTER_KEY", "master"); + command.env("CRANK_MASTER_KEY", TEST_MASTER_KEY); for (name, value) in entries { command.env(name, value); } @@ -63,3 +73,29 @@ async fn fresh_database_startup_is_read_only() { .unwrap(); assert!(!present); } + +#[tokio::test] +async fn master_key_mismatch_blocks_startup_with_safe_diagnostic() { + let database_url = crank_test_support::postgres_schema_url("mcp_master_key_mismatch").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + let registry = PostgresRegistry::connect(&database_url).await.unwrap(); + let crypto = SecretCrypto::new(REGISTERED_MASTER_KEY).unwrap(); + registry + .verify_or_register_master_key_identity(MasterKeyIdentityCandidate { + epoch: 1, + fingerprint: crypto.master_key_fingerprint(), + cipher_contract: MASTER_KEY_CIPHER_CONTRACT, + observed_at: &OffsetDateTime::parse("2026-08-21T00:00:00Z", &Rfc3339).unwrap(), + }) + .await + .unwrap(); + + let stderr = run_with(&[ + ("CRANK_DATABASE_URL", &database_url), + ("CRANK_MASTER_KEY", WRONG_MASTER_KEY), + ]); + assert!(stderr.contains("master_key_identity_mismatch"), "{stderr}"); + assert!(!stderr.contains("registered-master-key")); + assert!(!stderr.contains("wrong-master-key")); +} diff --git a/apps/mcp-server/tests/dc08.rs b/apps/mcp-server/tests/dc08.rs index e3b3ed3..bd28dfe 100644 --- a/apps/mcp-server/tests/dc08.rs +++ b/apps/mcp-server/tests/dc08.rs @@ -114,11 +114,16 @@ async fn preserves_mcp_result_when_postgres_rejects_invocation_history() { .list_invocation_logs(ListInvocationLogsQuery { workspace_id: &WorkspaceId::new("ws_default"), level: None, + status: None, + outcome_group: None, search_text: None, source: None, operation_id: Some(&operation.id), agent_id: None, created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 10, }) .await diff --git a/apps/mcp-server/tests/integration/catalog_access.rs b/apps/mcp-server/tests/integration/catalog_access.rs index bee481a..975ee46 100644 --- a/apps/mcp-server/tests/integration/catalog_access.rs +++ b/apps/mcp-server/tests/integration/catalog_access.rs @@ -23,14 +23,14 @@ use crank_core::{ ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, InvocationSource, Operation, OperationApprovalMode, OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, - PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, - WorkspaceId, + PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, + ToolDescription, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ - CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest, - ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest, - SaveAgentBindingsRequest, + CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest, + CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, + PublishRequest, SaveAgentBindingsRequest, }; use crank_runtime::{ InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor, @@ -141,7 +141,7 @@ fn build_test_app_with_store( registry, refresh_interval, public_base_url, - SecretCrypto::new("test-master-key").unwrap(), + SecretCrypto::new("test-master-key-00000000000000000000000000000000").unwrap(), crank_runtime::community_with_outbound_policy( crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]), ) @@ -153,6 +153,17 @@ fn build_test_app_with_store( ) } +async fn assert_revoked_mcp_request_is_denied( + response: reqwest::Response, + api_key: &str, + key_id: &PlatformApiKeyId, +) { + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let denied_body = response.text().await.unwrap(); + assert!(!denied_body.contains(api_key)); + assert!(!denied_body.contains(key_id.as_str())); +} + #[tokio::test] async fn refreshes_published_tools_without_restart() { let registry = test_registry().await; @@ -204,12 +215,33 @@ async fn refreshes_published_tools_without_restart() { }) .await .unwrap(); + let agent_id = test_agent_id("sales-refresh"); + let draft_v2 = AgentVersion { + agent_id: agent_id.clone(), + version: 2, + status: AgentStatus::Draft, + instructions: json!({}), + tool_selection_policy: Default::default(), + created_at: OffsetDateTime::parse("2026-03-26T10:01:00Z", &Rfc3339).unwrap(), + }; registry - .save_agent_bindings(SaveAgentBindingsRequest { + .create_agent_draft_version(CreateAgentDraftVersionRequest { workspace_id: &test_workspace_id(), - agent_id: &test_agent_id("sales-refresh"), - agent_version: 1, + agent_id: &agent_id, + version: &draft_v2, bindings: &[binding_for_operation(&operation)], + updated_at: &OffsetDateTime::parse("2026-03-26T10:01:00Z", &Rfc3339).unwrap(), + }) + .await + .unwrap(); + registry + .publish_agent(PublishAgentRequest { + workspace_id: &test_workspace_id(), + agent_id: &agent_id, + version: 2, + published_at: &OffsetDateTime::parse("2026-03-26T10:02:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + expected_state: None, }) .await .unwrap(); @@ -236,7 +268,7 @@ async fn refreshes_published_tools_without_restart() { } #[tokio::test] -async fn shares_published_catalog_snapshot_across_instances() { +async fn shared_catalog_cache_does_not_serve_stale_snapshot_after_unpublish() { let registry = test_registry().await; let upstream_base_url = spawn_upstream_server().await; let operation = test_operation(&upstream_base_url, "crm_catalog_shared"); @@ -274,6 +306,7 @@ async fn shares_published_catalog_snapshot_across_instances() { &test_workspace_id(), &test_agent_id("sales-shared-catalog"), &OffsetDateTime::parse("2026-03-26T10:05:00Z", &Rfc3339).unwrap(), + None, ) .await .unwrap(); @@ -283,13 +316,15 @@ async fn shares_published_catalog_snapshot_across_instances() { Duration::from_secs(60), coordination_store, ); - let tools_b = catalog_b + let error = catalog_b .list_tools(test_workspace_slug(), "sales-shared-catalog") .await - .unwrap(); + .expect_err("unpublished Agent must not be served from shared cache"); - assert_eq!(tools_b.len(), 1); - assert_eq!(tools_b[0].tool_name, operation.name); + assert!(matches!( + error, + crank_registry::RegistryError::PublishedAgentNotFound { .. } + )); } #[tokio::test] @@ -456,6 +491,135 @@ async fn rejects_initialize_with_key_from_different_agent() { assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn revoked_mcp_key_stops_existing_session_without_restart() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url, "crm_revoked_key"); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-revoked-key").await; + let api_key = create_platform_api_key( + ®istry, + "sales-revoked-key", + "mcp-revoked-boundary", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-revoked-key"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let before_revoke = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }), + ) + .await; + assert_eq!( + before_revoke["result"]["tools"][0]["name"], + "crm_revoked_key" + ); + + let key_id = registry + .list_platform_api_keys_for_agent(&test_workspace_id(), &test_agent_id("sales-revoked-key")) + .await + .unwrap() + .into_iter() + .find(|record| record.api_key.name == "mcp-revoked-boundary") + .unwrap() + .api_key + .id; + registry + .revoke_platform_api_key_for_agent( + &test_workspace_id(), + &test_agent_id("sales-revoked-key"), + &key_id, + &OffsetDateTime::now_utc(), + ) + .await + .unwrap(); + + let rejected_initialize = post_jsonrpc_response( + &client, + &mcp_url, + &api_key, + None, + None, + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25" + } + }), + ) + .await; + assert_revoked_mcp_request_is_denied(rejected_initialize, &api_key, &key_id).await; + + let rejected_tools_list = post_jsonrpc_response( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + None, + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/list", + "params": {} + }), + ) + .await; + assert_revoked_mcp_request_is_denied(rejected_tools_list, &api_key, &key_id).await; + + let rejected_tools_call = post_jsonrpc_response( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + None, + json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "crm_revoked_key", + "arguments": {} + } + }), + ) + .await; + assert_revoked_mcp_request_is_denied(rejected_tools_call, &api_key, &key_id).await; +} + #[tokio::test] async fn rejects_initialize_without_platform_api_key() { let registry = test_registry().await; diff --git a/apps/mcp-server/tests/integration/catalog_access/approval_access.rs b/apps/mcp-server/tests/integration/catalog_access/approval_access.rs index a7affa3..98ed708 100644 --- a/apps/mcp-server/tests/integration/catalog_access/approval_access.rs +++ b/apps/mcp-server/tests/integration/catalog_access/approval_access.rs @@ -1,5 +1,8 @@ use super::*; +mod pending; +mod revocation; + #[tokio::test] async fn approval_key_lists_and_decides_pending_requests() { let registry = test_registry().await; @@ -23,6 +26,8 @@ async fn approval_key_lists_and_decides_pending_requests() { operation_version: 1, status: ApprovalRequestStatus::Pending, risk_level: OperationApprovalRiskLevel::Dangerous, + request_id: None, + trace_id: None, request_payload: json!({"email": "ada@example.com"}), response_payload: None, created_at: OffsetDateTime::now_utc(), @@ -164,11 +169,16 @@ async fn approval_key_lists_and_decides_pending_requests() { .list_invocation_logs(ListInvocationLogsQuery { workspace_id: &test_workspace_id(), level: None, + status: None, + outcome_group: None, search_text: None, source: Some(InvocationSource::AgentToolCall), operation_id: Some(&operation.id), agent_id: Some(&test_agent_id("sales-human-approval")), created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 10, }) .await @@ -203,6 +213,8 @@ async fn approval_key_denies_without_executing_upstream() { operation_version: 1, status: ApprovalRequestStatus::Pending, risk_level: OperationApprovalRiskLevel::Dangerous, + request_id: None, + trace_id: None, request_payload: json!({"email": "deny@example.com"}), response_payload: None, created_at: OffsetDateTime::now_utc(), @@ -265,11 +277,16 @@ async fn approval_key_denies_without_executing_upstream() { .list_invocation_logs(ListInvocationLogsQuery { workspace_id: &test_workspace_id(), level: None, + status: None, + outcome_group: None, search_text: None, source: Some(InvocationSource::AgentToolCall), operation_id: Some(&operation.id), agent_id: Some(&test_agent_id("sales-human-deny")), created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 10, }) .await @@ -277,6 +294,112 @@ async fn approval_key_denies_without_executing_upstream() { assert!(logs.is_empty()); } +#[tokio::test] +async fn approval_key_allowed_origins_are_enforced_at_approval_boundary() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url, "crm_origin_guard"); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + publish_agent_with_bindings( + ®istry, + "sales-origin-guard", + vec![binding_for_operation(&operation)], + ) + .await; + let approval = ApprovalRequest { + id: ApprovalRequestId::new("approval_origin_guard_01"), + workspace_id: test_workspace_id(), + agent_id: test_agent_id("sales-origin-guard"), + operation_id: operation.id.clone(), + operation_version: 1, + status: ApprovalRequestStatus::Pending, + risk_level: OperationApprovalRiskLevel::Dangerous, + request_id: None, + trace_id: None, + request_payload: json!({"email": "origin@example.com"}), + response_payload: None, + created_at: OffsetDateTime::now_utc(), + expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5), + decided_at: None, + decided_by_key_id: None, + decision_note: None, + }; + registry + .create_approval_request(CreateApprovalRequest { + approval: &approval, + }) + .await + .unwrap(); + + let approval_secret = format!("crk_appr_origin_{}", uuid::Uuid::now_v7().simple()); + let approval_key = PlatformApiKey { + id: PlatformApiKeyId::new("pk_approval_origin_guard"), + workspace_id: test_workspace_id(), + agent_id: Some(test_agent_id("sales-origin-guard")), + key_kind: PlatformApiKeyKind::Approval, + name: "approval-origin-guard".to_owned(), + prefix: approval_secret.chars().take(16).collect(), + scopes: vec![PlatformApiKeyScope::ReadPending], + status: PlatformApiKeyStatus::Active, + created_at: OffsetDateTime::now_utc(), + last_used_at: None, + expires_at: None, + allowed_origins: vec!["https://allowed.example.test".to_owned()], + }; + registry + .create_platform_api_key(CreatePlatformApiKeyRequest { + api_key: &approval_key, + secret_hash: &hash_access_secret(&approval_secret), + }) + .await + .unwrap(); + + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let approvals_url = format!( + "{}/approvals", + agent_mcp_url(&base_url, "sales-origin-guard") + ); + + let rejected = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_secret}")) + .header(header::ORIGIN, "https://evil.example.test") + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN); + let rejected_body = rejected.text().await.unwrap(); + assert!(!rejected_body.contains("allowed.example.test")); + assert!(!rejected_body.contains("evil.example.test")); + assert!(!rejected_body.contains(&approval_secret)); + + let server_side_client = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_secret}")) + .send() + .await + .unwrap(); + assert_eq!(server_side_client.status(), reqwest::StatusCode::OK); + + let accepted = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_secret}")) + .header(header::ORIGIN, "https://allowed.example.test") + .send() + .await + .unwrap(); + assert_eq!(accepted.status(), reqwest::StatusCode::OK); +} + #[tokio::test] async fn approval_key_expires_without_executing_upstream() { let registry = test_registry().await; @@ -300,6 +423,8 @@ async fn approval_key_expires_without_executing_upstream() { operation_version: 1, status: ApprovalRequestStatus::Pending, risk_level: OperationApprovalRiskLevel::Dangerous, + request_id: None, + trace_id: None, request_payload: json!({"email": "expired@example.com"}), response_payload: None, created_at: OffsetDateTime::now_utc() - time::Duration::minutes(10), @@ -349,11 +474,16 @@ async fn approval_key_expires_without_executing_upstream() { .list_invocation_logs(ListInvocationLogsQuery { workspace_id: &test_workspace_id(), level: None, + status: None, + outcome_group: None, search_text: None, source: Some(InvocationSource::AgentToolCall), operation_id: Some(&operation.id), agent_id: Some(&test_agent_id("sales-human-expired")), created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 10, }) .await @@ -361,116 +491,6 @@ async fn approval_key_expires_without_executing_upstream() { assert!(logs.is_empty()); } -#[tokio::test] -async fn tool_call_with_approval_policy_creates_pending_request() { - let registry = test_registry().await; - let upstream_base_url = spawn_upstream_server().await; - let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval"); - operation.execution_config.approval_policy = Some(OperationApprovalPolicy { - required: true, - mode: OperationApprovalMode::Custom, - risk_level: OperationApprovalRiskLevel::Dangerous, - ttl_seconds: 300, - show_payload_preview: true, - payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, - elicitation_message: None, - }); - registry - .create_operation(&test_workspace_id(), &operation, Some("alice")) - .await - .unwrap(); - registry - .publish_operation(PublishRequest { - workspace_id: &test_workspace_id(), - operation_id: &operation.id, - version: 1, - published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-gated").await; - let api_key = create_platform_api_key( - ®istry, - "sales-gated", - "mcp-gated", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - let approval_key = - create_approval_platform_api_key(®istry, "sales-gated", "approval-gated").await; - - let base_url = spawn_mcp_server(build_test_app( - registry, - Duration::from_millis(0), - Some("https://crank.example.com".to_owned()), - )) - .await; - let client = reqwest::Client::new(); - let mcp_url = agent_mcp_url(&base_url, "sales-gated"); - let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; - - let tool_call = json!({ - "jsonrpc": "2.0", - "id": 9, - "method": "tools/call", - "params": { - "name": "crm_requires_human_approval", - "arguments": { - "email": "ada@example.com" - } - } - }); - let tool_result = post_jsonrpc( - &client, - &mcp_url, - &api_key, - Some(&initialized_session), - tool_call.clone(), - ) - .await; - - assert_eq!( - tool_result["result"]["structuredContent"]["status"], - "approval_required" - ); - assert_eq!(tool_result["result"]["isError"], false); - let approval_id = tool_result["result"]["structuredContent"]["approval_id"] - .as_str() - .unwrap(); - assert!(approval_id.starts_with("approval_")); - - let repeated_tool_result = post_jsonrpc( - &client, - &mcp_url, - &api_key, - Some(&initialized_session), - tool_call, - ) - .await; - assert_eq!( - repeated_tool_result["result"]["structuredContent"]["approval_id"], approval_id, - "deduplicated tools/call must return the persisted approval id", - ); - - let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated")); - let pending = client - .get(&approvals_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - assert_eq!(pending["items"].as_array().unwrap().len(), 1); - assert_eq!(pending["items"][0]["approval"]["id"], approval_id); - assert_eq!( - pending["items"][0]["approval"]["request_payload"]["email"], - "ada@example.com" - ); -} - #[tokio::test] async fn approval_http_endpoints_enforce_request_rate_limit() { let registry = test_registry().await; @@ -589,6 +609,8 @@ async fn recovery_does_not_repeat_interrupted_mutating_approval() { operation_version: operation.version, status: ApprovalRequestStatus::Pending, risk_level: OperationApprovalRiskLevel::Dangerous, + request_id: None, + trace_id: None, request_payload: json!({"email": "interrupted@example.com"}), response_payload: None, created_at: now - time::Duration::minutes(10), @@ -609,9 +631,12 @@ async fn recovery_does_not_repeat_interrupted_mutating_approval() { workspace_id: &approval.workspace_id, agent_id: &approval.agent_id, approval_id: &approval.id, + operation_id: &approval.operation_id, + operation_version: approval.operation_version, + request_payload: &approval.request_payload, status: ApprovalRequestStatus::Approved, decided_at: now - time::Duration::minutes(10), - decided_by_key_id: &approval_key_id, + decided_by_key_id: Some(&approval_key_id), response_payload: Some(json!({"approve": "yes"})), decision_note: None, }) @@ -666,7 +691,7 @@ fn build_test_app_with_approval_recovery(registry: PostgresRegistry) -> Router { registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), - SecretCrypto::new("test-master-key").unwrap(), + SecretCrypto::new("test-master-key-00000000000000000000000000000000").unwrap(), crank_runtime::community_with_outbound_policy( crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]), ) diff --git a/apps/mcp-server/tests/integration/catalog_access/approval_access/pending.rs b/apps/mcp-server/tests/integration/catalog_access/approval_access/pending.rs new file mode 100644 index 0000000..5b0e607 --- /dev/null +++ b/apps/mcp-server/tests/integration/catalog_access/approval_access/pending.rs @@ -0,0 +1,151 @@ +use super::*; + +#[tokio::test] +async fn tool_call_with_approval_policy_creates_pending_request() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval"); + operation.execution_config.approval_policy = Some(OperationApprovalPolicy { + required: true, + mode: OperationApprovalMode::Custom, + risk_level: OperationApprovalRiskLevel::Dangerous, + ttl_seconds: 300, + show_payload_preview: true, + payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + elicitation_message: None, + }); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-gated").await; + let api_key = create_platform_api_key( + ®istry, + "sales-gated", + "mcp-gated", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let approval_key = + create_approval_platform_api_key(®istry, "sales-gated", "approval-gated").await; + + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-gated"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let tool_call = json!({ + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { + "name": "crm_requires_human_approval", + "arguments": { + "email": "ada@example.com", + "api_key": "history-secret-canary" + } + } + }); + let tool_result = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + tool_call.clone(), + ) + .await; + + assert_eq!( + tool_result["result"]["structuredContent"]["status"], + "approval_required" + ); + assert_eq!(tool_result["result"]["isError"], false); + let approval_id = tool_result["result"]["structuredContent"]["approval_id"] + .as_str() + .unwrap(); + assert!(approval_id.starts_with("approval_")); + + let repeated_tool_result = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + tool_call, + ) + .await; + assert_eq!( + repeated_tool_result["result"]["structuredContent"]["approval_id"], approval_id, + "deduplicated tools/call must return the persisted approval id", + ); + + let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated")); + let pending = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(pending["items"].as_array().unwrap().len(), 1); + assert_eq!(pending["items"][0]["approval"]["id"], approval_id); + assert_eq!( + pending["items"][0]["approval"]["request_payload"]["email"], + "ada@example.com" + ); + let pending_preview = + serde_json::to_string(&pending["items"][0]["approval"]["request_payload"]).unwrap(); + assert!(!pending_preview.contains("history-secret-canary")); + assert!(pending_preview.contains("[REDACTED]")); + + let logs = registry + .list_invocation_logs(ListInvocationLogsQuery { + workspace_id: &test_workspace_id(), + level: None, + status: None, + outcome_group: None, + search_text: None, + source: Some(InvocationSource::AgentToolCall), + operation_id: Some(&operation.id), + agent_id: Some(&test_agent_id("sales-gated")), + created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, + limit: 10, + }) + .await + .unwrap(); + assert!(!logs.is_empty()); + let history_preview = serde_json::to_string( + &logs + .iter() + .map(|record| { + json!({ + "request": record.log.request_preview, + "response": record.log.response_preview, + }) + }) + .collect::>(), + ) + .unwrap(); + assert!(!history_preview.contains("history-secret-canary")); + assert!(!history_preview.contains("ada@example.com")); + assert!(history_preview.contains("approval_required")); +} diff --git a/apps/mcp-server/tests/integration/catalog_access/approval_access/revocation.rs b/apps/mcp-server/tests/integration/catalog_access/approval_access/revocation.rs new file mode 100644 index 0000000..7613f96 --- /dev/null +++ b/apps/mcp-server/tests/integration/catalog_access/approval_access/revocation.rs @@ -0,0 +1,137 @@ +use super::*; + +#[tokio::test] +async fn revoked_approval_key_stops_list_approve_and_deny_without_restart() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url, "crm_approval_revoked"); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + publish_agent_with_bindings( + ®istry, + "sales-approval-revoked", + vec![binding_for_operation(&operation)], + ) + .await; + let approval = ApprovalRequest { + id: ApprovalRequestId::new("approval_mcp_revoked_01"), + workspace_id: test_workspace_id(), + agent_id: test_agent_id("sales-approval-revoked"), + operation_id: operation.id.clone(), + operation_version: 1, + status: ApprovalRequestStatus::Pending, + risk_level: OperationApprovalRiskLevel::Dangerous, + request_id: None, + trace_id: None, + request_payload: json!({"email": "revoked@example.com"}), + response_payload: None, + created_at: OffsetDateTime::now_utc(), + expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5), + decided_at: None, + decided_by_key_id: None, + decision_note: None, + }; + registry + .create_approval_request(CreateApprovalRequest { + approval: &approval, + }) + .await + .unwrap(); + + let approval_key = create_approval_platform_api_key( + ®istry, + "sales-approval-revoked", + "approval-revoked-http", + ) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let approvals_url = format!( + "{}/approvals", + agent_mcp_url(&base_url, "sales-approval-revoked") + ); + let approve_url = format!( + "{}/approvals/{}/approve", + agent_mcp_url(&base_url, "sales-approval-revoked"), + approval.id + ); + let deny_url = format!( + "{}/approvals/{}/deny", + agent_mcp_url(&base_url, "sales-approval-revoked"), + approval.id + ); + + let before_revoke = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap(); + assert_eq!(before_revoke.status(), reqwest::StatusCode::OK); + + let approval_key_id = registry + .list_platform_api_keys_for_agent( + &test_workspace_id(), + &test_agent_id("sales-approval-revoked"), + ) + .await + .unwrap() + .into_iter() + .find(|record| record.api_key.name == "approval-revoked-http") + .unwrap() + .api_key + .id; + registry + .revoke_platform_api_key_for_agent( + &test_workspace_id(), + &test_agent_id("sales-approval-revoked"), + &approval_key_id, + &OffsetDateTime::now_utc(), + ) + .await + .unwrap(); + + let denied_list = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap(); + assert_eq!(denied_list.status(), reqwest::StatusCode::UNAUTHORIZED); + + let denied_approve = client + .post(&approve_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .json(&json!({ "approve": "yes" })) + .send() + .await + .unwrap(); + assert_eq!(denied_approve.status(), reqwest::StatusCode::UNAUTHORIZED); + + let denied_deny = client + .post(&deny_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .json(&json!({ "approve": "no" })) + .send() + .await + .unwrap(); + assert_eq!(denied_deny.status(), reqwest::StatusCode::UNAUTHORIZED); + + let current = registry + .get_approval_request_for_agent( + &test_workspace_id(), + &test_agent_id("sales-approval-revoked"), + &approval.id, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(current.approval.status, ApprovalRequestStatus::Pending); +} diff --git a/apps/mcp-server/tests/integration/common.rs b/apps/mcp-server/tests/integration/common.rs index 4a9b043..e7f44b8 100644 --- a/apps/mcp-server/tests/integration/common.rs +++ b/apps/mcp-server/tests/integration/common.rs @@ -134,7 +134,7 @@ fn build_test_app_with_store( registry, refresh_interval, public_base_url, - SecretCrypto::new("test-master-key").unwrap(), + SecretCrypto::new("test-master-key-00000000000000000000000000000000").unwrap(), crank_runtime::community_with_outbound_policy( crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]), ) @@ -417,6 +417,7 @@ pub(super) async fn publish_agent_with_policy( version: 1, published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), published_by: Some("alice"), + expected_state: None, }) .await .unwrap(); diff --git a/apps/mcp-server/tests/integration/execution_stages.rs b/apps/mcp-server/tests/integration/execution_stages.rs index 71abb29..b33ae3b 100644 --- a/apps/mcp-server/tests/integration/execution_stages.rs +++ b/apps/mcp-server/tests/integration/execution_stages.rs @@ -276,11 +276,16 @@ async fn exports_real_tool_stages_without_sensitive_data() { .list_invocation_logs(ListInvocationLogsQuery { workspace_id: &test_workspace_id(), level: None, + status: None, + outcome_group: None, search_text: None, source: Some(InvocationSource::AgentToolCall), operation_id: Some(&operation.id), agent_id: None, created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 10, }) .await diff --git a/apps/mcp-server/tests/integration/tool_search.rs b/apps/mcp-server/tests/integration/tool_search.rs index c561b60..ffb1dda 100644 --- a/apps/mcp-server/tests/integration/tool_search.rs +++ b/apps/mcp-server/tests/integration/tool_search.rs @@ -3,7 +3,8 @@ use super::common::*; use std::time::Duration; use crank_core::{ - PlatformApiKeyScope, ToolAccessMode, ToolGroup, ToolSearchSettings, ToolSelectionPolicy, + AgentId, PlatformApiKeyScope, ToolAccessMode, ToolGroup, ToolSearchSettings, + ToolSelectionPolicy, }; use crank_registry::PublishRequest; use serde_json::json; @@ -108,10 +109,16 @@ async fn search_mode_discovers_and_calls_tools_through_meta_tools() { search["result"]["structuredContent"]["tools"][0]["name"], "create_invoice" ); - assert_eq!( - search["result"]["structuredContent"]["catalog_revision"], - "agent-version-1" + assert!( + search["result"]["structuredContent"]["catalog_revision"] + .as_str() + .unwrap() + .starts_with("agent-version-1-catalog-revision-") ); + let catalog_revision = search["result"]["structuredContent"]["catalog_revision"] + .as_str() + .unwrap() + .to_owned(); let stale_call = post_jsonrpc( &client, @@ -131,7 +138,7 @@ async fn search_mode_discovers_and_calls_tools_through_meta_tools() { assert_eq!(stale_call["result"]["isError"], true); assert_eq!( stale_call["result"]["structuredContent"]["error"]["code"], - "catalog_revision_changed" + "agent_catalog_result_stale" ); let call = post_jsonrpc( @@ -144,7 +151,7 @@ async fn search_mode_discovers_and_calls_tools_through_meta_tools() { "params":{"name":"call_tool","arguments":{ "name":"create_invoice", "arguments":{"email":"user@example.com"}, - "catalog_revision":"agent-version-1" + "catalog_revision": catalog_revision }} }), ) @@ -152,3 +159,116 @@ async fn search_mode_discovers_and_calls_tools_through_meta_tools() { assert_eq!(call["result"]["isError"], false); assert_eq!(call["result"]["structuredContent"]["id"], "lead_123"); } + +#[tokio::test] +async fn stale_search_result_rejects_tool_call() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let invoice = test_operation(&upstream_base_url, "stale_invoice_tool"); + registry + .create_operation(&test_workspace_id(), &invoice, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &invoice.id, + version: 1, + published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_with_policy( + ®istry, + "stale-search-agent", + vec![binding_for_operation(&invoice)], + ToolSelectionPolicy { + mode: ToolAccessMode::Search, + groups: vec![ToolGroup { + id: "finance".to_owned(), + name: "Finance".to_owned(), + description: "Invoices and payments".to_owned(), + tool_names: vec![invoice.name.clone()], + }], + search: ToolSearchSettings { max_results: 5 }, + }, + ) + .await; + let agent_id = AgentId::new("agent_stale-search-agent"); + let api_key = create_platform_api_key( + ®istry, + "stale-search-agent", + "mcp-stale-search", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "stale-search-agent"); + let session = initialize_session(&client, &mcp_url, &api_key).await; + + let search = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&session), + json!({ + "jsonrpc":"2.0","id":3,"method":"tools/call", + "params":{"name":"search_tools","arguments":{"query":"invoice","group_ids":["finance"]}} + }), + ) + .await; + let stale_revision = search["result"]["structuredContent"]["catalog_revision"] + .as_str() + .unwrap() + .to_owned(); + + registry + .unpublish_agent( + &test_workspace_id(), + &agent_id, + &OffsetDateTime::parse("2026-03-26T10:01:00Z", &Rfc3339).unwrap(), + None, + ) + .await + .unwrap(); + registry + .publish_agent(crank_registry::PublishAgentRequest { + workspace_id: &test_workspace_id(), + agent_id: &agent_id, + version: 1, + published_at: &OffsetDateTime::parse("2026-03-26T10:02:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + expected_state: None, + }) + .await + .unwrap(); + + let stale_call = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&session), + json!({ + "jsonrpc":"2.0","id":4,"method":"tools/call", + "params":{"name":"call_tool","arguments":{ + "name":"stale_invoice_tool", + "arguments":{"email":"user@example.com"}, + "catalog_revision": stale_revision + }} + }), + ) + .await; + + assert_eq!(stale_call["result"]["isError"], true); + assert_eq!( + stale_call["result"]["structuredContent"]["error"]["code"], + "agent_catalog_result_stale" + ); +} diff --git a/apps/mcp-server/tests/integration/transport_protocol.rs b/apps/mcp-server/tests/integration/transport_protocol.rs index 809008b..9772f20 100644 --- a/apps/mcp-server/tests/integration/transport_protocol.rs +++ b/apps/mcp-server/tests/integration/transport_protocol.rs @@ -140,7 +140,7 @@ fn build_test_app_with_store( registry, refresh_interval, public_base_url, - SecretCrypto::new("test-master-key").unwrap(), + SecretCrypto::new("test-master-key-00000000000000000000000000000000").unwrap(), crank_runtime::community_with_outbound_policy( crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]), ) @@ -234,11 +234,16 @@ async fn initializes_lists_and_calls_published_tool_via_mcp() { .list_invocation_logs(ListInvocationLogsQuery { workspace_id: &test_workspace_id(), level: None, + status: None, + outcome_group: None, search_text: None, source: Some(crank_core::InvocationSource::AgentToolCall), operation_id: Some(&operation.id), agent_id: None, created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 10, }) .await @@ -333,11 +338,16 @@ async fn preserves_request_id_for_tool_call_invocations() { .list_invocation_logs(ListInvocationLogsQuery { workspace_id: &test_workspace_id(), level: None, + status: None, + outcome_group: None, search_text: None, source: Some(crank_core::InvocationSource::AgentToolCall), operation_id: Some(&operation.id), agent_id: None, created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 10, }) .await @@ -432,11 +442,16 @@ async fn generates_request_id_for_tool_call_responses_and_logs() { .list_invocation_logs(ListInvocationLogsQuery { workspace_id: &test_workspace_id(), level: None, + status: None, + outcome_group: None, search_text: None, source: Some(crank_core::InvocationSource::AgentToolCall), operation_id: Some(&operation.id), agent_id: None, created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, limit: 10, }) .await diff --git a/apps/mcp-server/tests/onboarding.rs b/apps/mcp-server/tests/onboarding.rs new file mode 100644 index 0000000..90b1b8a --- /dev/null +++ b/apps/mcp-server/tests/onboarding.rs @@ -0,0 +1,321 @@ +#[path = "integration/common.rs"] +mod common; + +use std::time::Duration; + +use crank_core::{ + ExecutionStage, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, + InvocationStatus, OutcomeCertainty, PlatformApiKeyScope, ProductEventKind, Retryability, +}; +use crank_registry::{ + CreateInvocationLogRequest, InvocationHistoryWriteOutcome, ListInvocationLogsQuery, + ListProductEventsQuery, PublishRequest, +}; +use serde_json::json; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use common::{ + agent_mcp_url, build_test_app, create_platform_api_key, initialize_session, post_jsonrpc, + publish_agent_for_operation, spawn_mcp_server, spawn_upstream_server, test_operation, + test_registry, test_workspace_id, +}; + +#[tokio::test] +async fn onboarding_completes_only_after_exact_key_successful_public_tool_call() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url, "onboarding_first_call"); + let mut operation_draft = operation.clone(); + operation_draft.status = crank_core::OperationStatus::Draft; + operation_draft.published_at = None; + + registry + .create_operation( + &test_workspace_id(), + &operation_draft, + Some("onboarding-test"), + ) + .await + .unwrap(); + let admin_test = InvocationLog { + id: InvocationLogId::new("log_onboarding_admin_test"), + workspace_id: test_workspace_id(), + agent_id: None, + platform_api_key_id: None, + operation_id: operation.id.clone(), + operation_version: Some(1), + source: InvocationSource::AdminTestRun, + level: InvocationLevel::Info, + status: InvocationStatus::Ok, + tool_name: operation.name.clone(), + message: "admin test succeeded".to_owned(), + request_id: Some("req_onboarding_admin_test".to_owned()), + trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_owned()), + status_code: Some(200), + duration_ms: 1, + error_kind: None, + execution_stage: Some(ExecutionStage::Runtime), + execution_error_code: None, + retryability: Some(Retryability::Never), + outcome_certainty: Some(OutcomeCertainty::Certain), + request_preview: json!({}), + response_preview: json!({"ok": true}), + created_at: OffsetDateTime::parse("2026-08-23T07:59:00Z", &Rfc3339).unwrap(), + }; + assert_eq!( + registry + .create_invocation_log(CreateInvocationLogRequest { log: &admin_test }) + .await, + InvocationHistoryWriteOutcome::Recorded + ); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: &OffsetDateTime::parse("2026-08-23T08:00:00Z", &Rfc3339).unwrap(), + published_by: Some("onboarding-test"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "onboarding-agent").await; + + let selected_key_name = "onboarding-raw-key-canary"; + let selected_key_id = format!("pk_{selected_key_name}"); + let selected_key = create_platform_api_key( + ®istry, + "onboarding-agent", + selected_key_name, + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let other_key = create_platform_api_key( + ®istry, + "onboarding-agent", + "onboarding-other", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let pre_call_projection = registry + .get_onboarding_projection(&test_workspace_id()) + .await + .unwrap(); + assert!( + pre_call_projection + .step(crank_core::OnboardingStepId::PublishOperation) + .is_some_and(|step| step.completed), + "fixture must expose a Published Operation: {pre_call_projection:#?}" + ); + + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "onboarding-agent"); + + let selected_session = initialize_session(&client, &mcp_url, &selected_key).await; + let listed = post_jsonrpc( + &client, + &mcp_url, + &selected_key, + Some(&selected_session), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}), + ) + .await; + assert_eq!(listed["result"]["tools"][0]["name"], operation.name); + assert!( + exact_key_successes(®istry, &operation.id, &selected_key_id) + .await + .is_empty(), + "initialize and tools/list must not complete onboarding" + ); + + let failed = post_jsonrpc( + &client, + &mcp_url, + &selected_key, + Some(&selected_session), + json!({ + "jsonrpc":"2.0", + "id":3, + "method":"tools/call", + "params":{"name":operation.name,"arguments":{}} + }), + ) + .await; + assert_eq!(failed["result"]["isError"], true); + assert!( + exact_key_successes(®istry, &operation.id, &selected_key_id) + .await + .is_empty(), + "a failed tools/call must not complete onboarding" + ); + + let other_session = initialize_session(&client, &mcp_url, &other_key).await; + let other_success = post_jsonrpc( + &client, + &mcp_url, + &other_key, + Some(&other_session), + json!({ + "jsonrpc":"2.0", + "id":4, + "method":"tools/call", + "params":{ + "name":operation.name, + "arguments":{"email":"other@example.com"} + } + }), + ) + .await; + assert_eq!(other_success["result"]["isError"], false); + assert!( + exact_key_successes(®istry, &operation.id, &selected_key_id) + .await + .is_empty(), + "a successful tools/call made with another key must not complete onboarding" + ); + + let selected_success = post_jsonrpc( + &client, + &mcp_url, + &selected_key, + Some(&selected_session), + json!({ + "jsonrpc":"2.0", + "id":5, + "method":"tools/call", + "params":{ + "name":operation.name, + "arguments":{"email":"selected@example.com"} + } + }), + ) + .await; + assert_eq!(selected_success["result"]["isError"], false); + + let exact_successes = exact_key_successes(®istry, &operation.id, &selected_key_id).await; + assert_eq!( + exact_successes.len(), + 1, + "onboarding must complete from exactly one successful public tools/call made with the selected key" + ); + assert_eq!( + exact_successes[0]["platform_api_key_id"], selected_key_id, + "successful invocation evidence must retain the typed key identity" + ); + assert!( + exact_successes[0]["request_id"] + .as_str() + .is_some_and(|id| !id.is_empty()) + ); + assert!( + exact_successes[0]["trace_id"] + .as_str() + .is_some_and(|id| id.len() == 32) + ); + + // A direct MCP call can precede the first onboarding snapshot. The later + // server-owned eligibility write must repair the completion event without + // replacing the invocation timestamp that anchored the exact lineage. + let projection = registry + .ensure_onboarding_eligibility(&test_workspace_id(), OffsetDateTime::now_utc()) + .await + .unwrap(); + let operation_summary = registry + .get_operation_summary(&test_workspace_id(), &operation.id) + .await + .unwrap(); + assert!( + projection.completed, + "projection did not complete: {projection:#?}; operation: {operation_summary:#?}" + ); + let completion_events = registry + .list_product_events(ListProductEventsQuery { + workspace_id: &test_workspace_id(), + kind: Some(ProductEventKind::OnboardingCompleted), + created_after: OffsetDateTime::UNIX_EPOCH, + created_before: OffsetDateTime::now_utc() + time::Duration::minutes(1), + limit: 10, + }) + .await + .unwrap(); + assert_eq!(completion_events.len(), 1); + assert_eq!( + completion_events[0].event.occurred_at, + projection.first_call_at.unwrap() + ); + + let persisted_evidence = invocation_evidence(®istry, &operation.id).await; + assert!(!persisted_evidence.is_empty()); + for evidence in persisted_evidence { + let serialized = serde_json::to_string(&evidence).unwrap(); + assert!( + !serialized.contains(&selected_key), + "raw Bearer canary must never appear in serialized invocation evidence" + ); + assert!( + !serialized.contains(&other_key), + "another raw Bearer secret must never appear in serialized invocation evidence" + ); + for field in ["message", "request_preview", "response_preview"] { + let persisted_field = serde_json::to_string(&evidence[field]).unwrap(); + assert!( + !persisted_field.contains(&selected_key), + "raw Bearer canary leaked into persisted {field}" + ); + assert!( + !persisted_field.contains(&other_key), + "another raw Bearer secret leaked into persisted {field}" + ); + } + } +} + +async fn exact_key_successes( + registry: &crank_registry::PostgresRegistry, + operation_id: &crank_core::OperationId, + selected_key_id: &str, +) -> Vec { + invocation_evidence(registry, operation_id) + .await + .into_iter() + .filter(|log| { + log.get("platform_api_key_id") + .and_then(serde_json::Value::as_str) + == Some(selected_key_id) + && log.get("status").and_then(serde_json::Value::as_str) == Some("ok") + }) + .collect() +} + +async fn invocation_evidence( + registry: &crank_registry::PostgresRegistry, + operation_id: &crank_core::OperationId, +) -> Vec { + registry + .list_invocation_logs(ListInvocationLogsQuery { + workspace_id: &test_workspace_id(), + level: None, + status: None, + outcome_group: None, + search_text: None, + source: Some(InvocationSource::AgentToolCall), + operation_id: Some(operation_id), + agent_id: None, + created_after: None, + created_before: None, + cursor_created_at: None, + cursor_id: None, + limit: 100, + }) + .await + .unwrap() + .into_iter() + .map(|record| serde_json::to_value(record.log).unwrap()) + .collect() +} diff --git a/apps/ui/css/logs.css b/apps/ui/css/logs.css index 87e0127..3e34430 100644 --- a/apps/ui/css/logs.css +++ b/apps/ui/css/logs.css @@ -135,6 +135,26 @@ .refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); } +.refresh-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.log-pagination { + display: flex; + justify-content: center; + padding: 12px 20px 18px; +} + +.log-pagination .refresh-btn { + margin-left: 0; +} + +.log-id-filter { + width: 180px; + margin: 0; +} + .approval-panel { margin-bottom: 18px; } diff --git a/apps/ui/css/onboarding.css b/apps/ui/css/onboarding.css new file mode 100644 index 0000000..e619074 --- /dev/null +++ b/apps/ui/css/onboarding.css @@ -0,0 +1,82 @@ +.onboarding-trigger { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 780; + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 9px 14px; + border: 1px solid var(--border-color, #30363d); + border-radius: 999px; + background: var(--surface-raised, #161b22); + color: var(--text-primary, #f0f6fc); + box-shadow: 0 8px 28px rgba(0, 0, 0, .28); + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.onboarding-trigger:hover { border-color: var(--accent, #58a6ff); } +.onboarding-trigger:focus-visible, +.onboarding-panel button:focus-visible, +.onboarding-panel a:focus-visible { outline: 2px solid var(--accent, #58a6ff); outline-offset: 2px; } + +.onboarding-panel { + position: fixed; + right: 22px; + bottom: 76px; + z-index: 790; + width: min(410px, calc(100vw - 28px)); + max-height: min(700px, calc(100vh - 104px)); + overflow: auto; + scroll-behavior: auto; + border: 1px solid var(--border-color, #30363d); + border-radius: 14px; + background: var(--surface-raised, #161b22); + color: var(--text-primary, #f0f6fc); + box-shadow: 0 18px 48px rgba(0, 0, 0, .4); +} + +.onboarding-panel[hidden] { display: none; } +.onboarding-head { display: flex; justify-content: space-between; gap: 12px; padding: 17px 18px 12px; border-bottom: 1px solid var(--border-color, #30363d); } +.onboarding-title { margin: 0; font-size: 17px; } +.onboarding-subtitle { margin: 4px 0 0; color: var(--text-muted, #8b949e); font-size: 12px; } +.onboarding-head-actions { display: flex; gap: 4px; } +.onboarding-icon-button { border: 0; background: transparent; color: inherit; cursor: pointer; border-radius: 6px; min-width: 32px; min-height: 32px; } +.onboarding-body { padding: 14px 18px 18px; } +.onboarding-status { min-height: 20px; color: var(--text-muted, #8b949e); font-size: 12px; margin-bottom: 10px; } +.onboarding-error { padding: 12px; border: 1px solid var(--danger, #f85149); border-radius: 8px; color: var(--danger, #f85149); } +.onboarding-error-ids { margin-top: 6px; overflow-wrap: anywhere; font-size: 12px; } +.onboarding-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.onboarding-step { display: grid; grid-template-columns: 26px 1fr; gap: 9px; padding: 10px; border: 1px solid var(--border-color, #30363d); border-radius: 9px; } +.onboarding-step[aria-current="step"] { border-color: var(--accent, #58a6ff); background: rgba(88, 166, 255, .08); } +.onboarding-step.is-regressed { border-color: var(--amber, #d29922); } +.onboarding-step-marker { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--surface-muted, #21262d); font-size: 12px; } +.onboarding-step.is-complete .onboarding-step-marker { background: var(--success, #3fb950); color: #07140a; } +.onboarding-step-title { font-weight: 600; font-size: 13px; } +.onboarding-step-reason { margin-top: 3px; color: var(--text-muted, #8b949e); font-size: 12px; } +.onboarding-step-action { margin-top: 8px; border: 0; border-radius: 7px; padding: 7px 10px; background: var(--accent, #58a6ff); color: #08111c; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; } +.onboarding-completion { padding: 12px; border: 1px solid var(--success, #3fb950); border-radius: 9px; overflow-wrap: anywhere; } +.onboarding-completion a { color: var(--accent, #58a6ff); } +.onboarding-first-call-evidence { display: grid; grid-template-columns: max-content 1fr; gap: 4px 10px; margin: 10px 0; font-size: 12px; } +.onboarding-first-call-evidence dt { color: var(--text-muted, #8b949e); } +.onboarding-first-call-evidence dd { margin: 0; font-family: var(--font-mono, monospace); overflow-wrap: anywhere; } +.onboarding-deep-link-stale { position: fixed; left: 50%; top: 76px; z-index: 810; width: min(520px, calc(100vw - 28px)); transform: translateX(-50%); padding: 14px; border: 1px solid var(--amber, #d29922); border-radius: 10px; background: var(--surface-raised, #161b22); color: var(--text-primary, #f0f6fc); box-shadow: 0 12px 36px rgba(0, 0, 0, .35); } +.onboarding-footer { display: flex; justify-content: space-between; gap: 8px; margin-top: 12px; } +.onboarding-link-button { border: 0; background: transparent; color: var(--text-muted, #8b949e); text-decoration: underline; cursor: pointer; font: inherit; font-size: 12px; } + +/* Active task surfaces own the pointer plane. The optional onboarding helper + must never cover drawer controls while guiding the user through that drawer. */ +body:has(.drawer.open) .onboarding-trigger, +body:has(.drawer.open) .onboarding-panel { z-index: 149; } + +@media (max-width: 640px) { + .onboarding-trigger { right: 14px; bottom: 14px; } + .onboarding-panel { right: 14px; bottom: 68px; } +} + +@media (prefers-reduced-motion: reduce) { + .onboarding-panel, .onboarding-trigger { transition: none !important; animation: none !important; scroll-behavior: auto; } +} diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index 86dec63..f4628a2 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -215,6 +215,7 @@
+
+ +