38 Commits

Author SHA1 Message Date
bsodfather c2556ec236 fix(docker): use HTTPS Debian package mirrors
CI / Rust Checks (push) Failing after 3m12s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped
2026-08-29 21:10:00 +03:00
bsodfather 55209a9bbc feat(import): resolve references and schema composition 2026-08-29 08:54:26 +03:00
bsodfather 6c2a3712d8 feat(import): add deterministic OpenAPI normalized IR
CI / Rust Checks (push) Failing after 3m12s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped
2026-08-29 04:15:37 +03:00
bsodfather bc03c33387 fix(openapi): harden story 2.1 production lifecycle
CI / Rust Checks (push) Failing after 4m6s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped
2026-08-29 00:48:22 +03:00
bsodfather 2c94af6791 feat(openapi): complete upload preview and UI evidence 2026-08-28 15:38:14 +03:00
bsodfather d2849ea3fe feat(artifacts): add fenced reconciliation recovery 2026-08-28 00:44:27 +03:00
bsodfather 8784964fb2 fix(artifacts): harden reconciliation scanner 2026-08-27 12:48:49 +03:00
bsodfather 889b1bdb57 feat(artifacts): add bounded reconciliation 2026-08-27 07:04:22 +03:00
bsodfather 38ba898b72 merge: complete story 2.1b artifact source metadata 2026-08-27 02:18:16 +03:00
bsodfather 2eb185b14a feat(registry): add workspace-scoped artifact metadata 2026-08-27 02:18:09 +03:00
bsodfather 497e1b740f feat(artifacts): add immutable artifact store 2026-08-25 18:06:33 +03:00
bsodfather 182bde8ac0 feat: complete Epic 1 production foundation 2026-08-25 01:24:11 +03:00
bsodfather 767428436d docs: freeze story 1.6 review evidence 2026-08-14 13:59:30 +03:00
bsodfather b7face0e94 fix: harden typed metrics review findings 2026-08-14 13:50:04 +03:00
bsodfather 996a5461de docs: refresh metrics evidence provenance 2026-08-14 12:27:30 +03:00
bsodfather 5fa5f6efe4 test: measure metrics allocation overhead 2026-08-14 12:14:05 +03:00
bsodfather 62d98a8846 docs: freeze metrics foundation evidence 2026-08-14 12:12:11 +03:00
bsodfather 8e709acea9 feat: freeze typed metrics registry and bounded exemplars 2026-08-14 11:52:00 +03:00
bsodfather 17c8e3a8f0 docs: freeze capability evidence for story 1.5 2026-08-14 00:39:12 +03:00
bsodfather f6fc2e5c9b feat: harden community production foundation through story 1.5 2026-08-14 00:21:59 +03:00
bsodfather c30461cc92 исправить: закрыть ревью критических ошибок
CI / Rust Checks (pull_request) Successful in 6m4s
CI / UI Checks (pull_request) Successful in 5s
CI / Community Image Smoke (pull_request) Successful in 4m27s
CI / Frontend E2E (pull_request) Successful in 5m19s
CI / Deploy (pull_request) Has been skipped
CI / Rust Checks (push) Successful in 6m5s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 1m5s
CI / Frontend E2E (push) Successful in 3m54s
CI / Deploy (push) Failing after 45s
2026-07-31 09:31:38 +03:00
bsodfather 9b1a739e39 наблюдаемость: ввести безопасный контракт метрик
CI / Rust Checks (pull_request) Successful in 6m15s
CI / UI Checks (pull_request) Successful in 5s
CI / Community Image Smoke (pull_request) Successful in 4m25s
CI / Frontend E2E (pull_request) Successful in 5m17s
CI / Deploy (pull_request) Has been skipped
CI / Rust Checks (push) Successful in 6m9s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 1m3s
CI / Frontend E2E (push) Successful in 3m47s
CI / Deploy (push) Failing after 3s
2026-07-31 05:04:08 +03:00
bsodfather ec2453c00f исправить: выполнять smoke внутри Compose сети
CI / Rust Checks (pull_request) Successful in 5m49s
CI / UI Checks (pull_request) Successful in 5s
CI / Community Image Smoke (pull_request) Successful in 1m11s
CI / Frontend E2E (pull_request) Successful in 3m48s
CI / Deploy (pull_request) Has been skipped
CI / Rust Checks (push) Successful in 5m50s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 1m5s
CI / Frontend E2E (push) Successful in 3m57s
CI / Deploy (push) Failing after 3s
2026-07-31 04:01:13 +03:00
bsodfather 242618e807 исправить: дождаться опубликованного порта UI
CI / Rust Checks (pull_request) Successful in 5m56s
CI / UI Checks (pull_request) Successful in 5s
CI / Community Image Smoke (pull_request) Failing after 1m32s
CI / Frontend E2E (pull_request) Successful in 3m47s
CI / Deploy (pull_request) Has been skipped
2026-07-31 03:51:09 +03:00
bsodfather 511c26ea18 исправить: упорядочить запуск Community UI
CI / Rust Checks (pull_request) Successful in 6m41s
CI / UI Checks (pull_request) Successful in 4s
CI / Community Image Smoke (pull_request) Failing after 1m3s
CI / Frontend E2E (pull_request) Successful in 3m50s
CI / Deploy (pull_request) Has been skipped
2026-07-31 03:36:53 +03:00
bsodfather a02acf5db3 исправить: изолировать порты smoke-теста
CI / Rust Checks (pull_request) Successful in 5m54s
CI / UI Checks (pull_request) Successful in 6s
CI / Community Image Smoke (pull_request) Failing after 36s
CI / Frontend E2E (pull_request) Successful in 4m31s
CI / Deploy (pull_request) Has been skipped
2026-07-31 03:25:55 +03:00
bsodfather 9a7d60593a исправить: закрыть ревью сквозной корреляции
CI / Rust Checks (pull_request) Successful in 8m33s
CI / UI Checks (pull_request) Successful in 5s
CI / Frontend E2E (pull_request) Successful in 6m51s
CI / Community Image Smoke (pull_request) Failing after 9m10s
CI / Deploy (pull_request) Has been skipped
2026-07-31 03:01:51 +03:00
bsodfather 0e8f1ca03a наблюдаемость: завершить базовый контур Community
CI / Rust Checks (push) Failing after 4m28s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped
Добавить структурированные журналы, метрики, трассировку и безопасный канал критических ошибок. Усилить границы рантайма, тесты, проверку зависимостей и сценарии развёртывания.
2026-07-31 01:01:14 +03:00
bsodfather 99bd05c145 агенты: добавить поиск инструментов по каталогу 2026-07-21 13:12:46 +03:00
bsodfather 63f8ee333f наблюдаемость: измерять бюджет каталога MCP
CI / Rust Checks (push) Successful in 12m5s
CI / UI Checks (push) Successful in 9s
CI / Deployment Manifests (push) Successful in 6s
CI / Frontend E2E (push) Failing after 30s
CI / Deploy (push) Has been skipped
2026-07-21 01:59:09 +03:00
bsodfather 0241d186ea Выровнять шкалу шагов мастера
CI / Rust Checks (push) Successful in 6m17s
CI / UI Checks (push) Successful in 8s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m21s
CI / Deploy (push) Successful in 2m42s
2026-07-12 13:26:13 +03:00
bsodfather 46892ee61c Усилить безопасность веб-интерфейса
CI / Rust Checks (push) Successful in 5m14s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 2m58s
CI / Deploy (push) Successful in 1m44s
2026-07-11 17:12:50 +03:00
bsodfather 8318e4b560 Усилить безопасность и надёжность выполнения операций
CI / Rust Checks (push) Successful in 5m7s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m9s
CI / Deploy (push) Successful in 1m41s
2026-07-11 14:08:07 +03:00
bsodfather 626f2845e2 Усилить проверку источника и ограничение запросов
CI / Rust Checks (push) Successful in 5m7s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m5s
CI / Deploy (push) Successful in 1m37s
2026-07-11 10:54:17 +03:00
bsodfather 502e339809 Адаптировать wizard под мобильный экран
CI / Rust Checks (push) Successful in 6m25s
CI / UI Checks (push) Successful in 8s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m29s
CI / Deploy (push) Successful in 2m44s
2026-07-10 01:05:12 +03:00
bsodfather dca97bd69b Включать мобильное меню раньше
CI / Rust Checks (push) Successful in 5m26s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 3m7s
CI / Deploy (push) Successful in 2m58s
2026-07-09 00:43:30 +03:00
Codex 061873058e ci: install rust toolchain from project config
CI / Rust Checks (push) Successful in 6m43s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m49s
CI / Deploy (push) Successful in 3m11s
2026-07-06 20:09:26 +00:00
bsodfather c98c7c8ce2 Merge pull request 'Обновил зависимости проекта' (#1) from chore/update-dependencies into main
CI / Rust Checks (push) Successful in 5m10s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m6s
CI / Deploy (push) Successful in 31s
Reviewed-on: #1
2026-07-06 19:49:54 +00:00
533 changed files with 98449 additions and 8059 deletions
+2
View File
@@ -0,0 +1,2 @@
[build]
jobs = 2
+56 -21
View File
@@ -1,36 +1,71 @@
POSTGRES_DB=crank # Deployment-only image and publication settings.
POSTGRES_USER=crank CRANK_ADMIN_API_IMAGE=crank/admin-api:dev
POSTGRES_PASSWORD=change-me CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev
CRANK_UI_IMAGE=crank/ui:dev
CRANK_PUBLISH_BIND=127.0.0.1
# BEGIN GENERATED CRANK RUNTIME CONFIG
CRANK_DATABASE_URL=
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=crank
POSTGRES_USER=crank
POSTGRES_PASSWORD=
POSTGRES_MAX_CONNECTIONS=20 POSTGRES_MAX_CONNECTIONS=20
POSTGRES_MIN_CONNECTIONS=2 POSTGRES_MIN_CONNECTIONS=2
POSTGRES_ACQUIRE_TIMEOUT_MS=5000 POSTGRES_ACQUIRE_TIMEOUT_MS=5000
POSTGRES_IDLE_TIMEOUT_MS=600000 POSTGRES_IDLE_TIMEOUT_MS=600000
POSTGRES_MAX_LIFETIME_MS=1800000 POSTGRES_MAX_LIFETIME_MS=1800000
CRANK_ADMIN_API_IMAGE=crank/admin-api:dev CRANK_MASTER_KEY=
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev CRANK_BASE_URL=http://localhost:3000
CRANK_UI_IMAGE=crank/ui:dev CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
CRANK_STORAGE_ROOT=/var/lib/crank/storage CRANK_CACHE_BACKEND=memory
CRANK_PUBLISH_BIND=127.0.0.1 CRANK_CACHE_URL=
CRANK_OUTBOUND_ALLOWED_HOSTS=
CRANK_OUTBOUND_DENIED_HOSTS=
CRANK_OUTBOUND_MAX_REQUEST_BYTES=4194304
CRANK_OUTBOUND_MAX_RESPONSE_BYTES=4194304
CRANK_IMPORT_EXTERNAL_REFERENCE_ALLOWED_URL_PREFIXES=
CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_DEPTH=8
CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_DOCUMENTS=32
CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_FETCH_BYTES=262144
CRANK_IMPORT_EXTERNAL_REFERENCE_FETCH_TIMEOUT_MS=10000
CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_EXPANDED_NODES=10000
CRANK_ENVIRONMENT=development
CRANK_LOG_LEVEL=
CRANK_SENTRY_DSN=
CRANK_METRICS_ENABLED=true
CRANK_METRICS_BEARER_TOKEN=
OTEL_EXPORTER_OTLP_ENDPOINT=
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=
OTEL_EXPORTER_OTLP_TIMEOUT=10000
OTEL_EXPORTER_OTLP_TRACES_TIMEOUT=
OTEL_EXPORTER_OTLP_HEADERS=
OTEL_EXPORTER_OTLP_TRACES_HEADERS=
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
OTEL_BSP_SCHEDULE_DELAY=5000
OTEL_BSP_EXPORT_TIMEOUT=30000
CRANK_ADMIN_BIND=0.0.0.0:3001 CRANK_ADMIN_BIND=0.0.0.0:3001
CRANK_ADMIN_METRICS_BIND=127.0.0.1:9464
CRANK_STORAGE_ROOT=/var/lib/crank/storage
CRANK_ADMIN_RATE_LIMIT_RPS=30 CRANK_ADMIN_RATE_LIMIT_RPS=30
CRANK_ADMIN_RATE_LIMIT_BURST=60 CRANK_ADMIN_RATE_LIMIT_BURST=60
CRANK_INVOCATION_LOG_RETENTION_DAYS=30
CRANK_SESSION_SECRET=
CRANK_PASSWORD_PEPPER=
CRANK_SESSION_TTL_HOURS=24
CRANK_TRUSTED_PROXY_IPS=
CRANK_BOOTSTRAP_ADMIN_EMAIL=
CRANK_BOOTSTRAP_ADMIN_PASSWORD=
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
CRANK_DEMO_SEED=false
CRANK_MCP_BIND=0.0.0.0:3002 CRANK_MCP_BIND=0.0.0.0:3002
CRANK_MCP_METRICS_BIND=127.0.0.1:9465
CRANK_MCP_REFRESH_MS=5000 CRANK_MCP_REFRESH_MS=5000
CRANK_MCP_RATE_LIMIT_RPS=60 CRANK_MCP_RATE_LIMIT_RPS=60
CRANK_MCP_RATE_LIMIT_BURST=120 CRANK_MCP_RATE_LIMIT_BURST=120
CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16 CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16 # END GENERATED CRANK RUNTIME CONFIG
CRANK_LOG_LEVEL=info
CRANK_MASTER_KEY=change-me-master-key
CRANK_SESSION_SECRET=change-me-session-secret
CRANK_PASSWORD_PEPPER=change-me-password-pepper
CRANK_SESSION_TTL_HOURS=24
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
CRANK_DEMO_SEED=true
CRANK_BASE_URL=https://crank.example.com
+218 -62
View File
@@ -22,15 +22,21 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Use preinstalled Rust toolchain - name: Install Rust toolchain
run: | run: |
set -eu set -eu
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/1.96.1-x86_64-unknown-linux-gnu" toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
if [ -z "$toolchain" ]; then
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
exit 1
fi
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
rustup default "$toolchain"
host="$(rustc -vV | sed -n 's/^host: //p')"
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
toolchain_bin="$toolchain_dir/bin" toolchain_bin="$toolchain_dir/bin"
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
echo "Rust 1.96.1 is not preinstalled at $toolchain_dir." >&2 echo "Rust $toolchain was not installed at $toolchain_dir." >&2
echo "Install it in the Gitea runner image/host before running CI:" >&2
echo "rustup toolchain install 1.96.1 --profile minimal --component clippy --component rustfmt" >&2
exit 1 exit 1
fi fi
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH" printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
@@ -49,9 +55,45 @@ jobs:
docker --version docker --version
docker info docker info
- name: Install dependency policy tool
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Run tooling unit tests - name: Run tooling unit tests
run: python3 -m unittest discover -s tests/unit run: python3 -m unittest discover -s tests/unit
- name: Check typed runtime configuration contract
run: |
cargo run -p crank-config --bin crank-config-contract -- --check
python3 scripts/check-runtime-config.py --root .
python3 scripts/check-config-boundaries.py --root .
- name: Check canonical migration contract
run: cargo run -p admin-api --bin crank-migrate -- plan --check
- name: Check typed metrics contract
run: |
cargo run -p crank-metrics --bin crank-metrics-contract -- --check
python3 scripts/check-metrics-boundaries.py --root .
- name: Check Capability Inventory
run: |
required_args=""
for number in $(seq 1 54); do
required_args="$required_args --required-fr FR-$number"
done
python3 scripts/validate-capability-inventory.py \
--root . \
--inventory docs/capability-inventory.json \
--schema docs/schemas/capability-inventory.schema.json \
$required_args
- name: Check Capability Baseline
run: |
python3 scripts/validate-capability-baseline.py \
--root . \
--manifest docs/capability-baseline/manifest.json \
--schema docs/schemas/capability-baseline.schema.json
- name: Check Community scope - name: Check Community scope
run: scripts/check-community-scope.sh run: scripts/check-community-scope.sh
@@ -61,6 +103,9 @@ jobs:
- name: Check Rust code health - name: Check Rust code health
run: scripts/check-rust-code-health.sh run: scripts/check-rust-code-health.sh
- name: Check dependency licenses and advisories
run: cargo deny --locked check advisories bans licenses sources
- name: Check Rust boundaries - name: Check Rust boundaries
run: scripts/check-rust-boundaries.sh run: scripts/check-rust-boundaries.sh
@@ -68,7 +113,7 @@ jobs:
run: cargo clippy --workspace --all-targets --all-features --jobs "$CARGO_BUILD_JOBS" -- -D warnings run: cargo clippy --workspace --all-targets --all-features --jobs "$CARGO_BUILD_JOBS" -- -D warnings
- name: Run tests - name: Run tests
run: cargo test --workspace --all-targets --jobs "$CARGO_BUILD_JOBS" run: cargo test --workspace --all-targets --jobs "$CARGO_BUILD_JOBS" -- --test-threads=1
ui: ui:
name: UI Checks name: UI Checks
@@ -89,6 +134,10 @@ jobs:
working-directory: apps/ui working-directory: apps/ui
run: npm ci run: npm ci
- name: Audit UI dependencies
working-directory: apps/ui
run: npm audit --audit-level=high
- name: Build UI bundle - name: Build UI bundle
working-directory: apps/ui working-directory: apps/ui
run: npm run build run: npm run build
@@ -121,15 +170,21 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Use preinstalled Rust toolchain - name: Install Rust toolchain
run: | run: |
set -eu set -eu
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/1.96.1-x86_64-unknown-linux-gnu" toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
if [ -z "$toolchain" ]; then
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
exit 1
fi
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
rustup default "$toolchain"
host="$(rustc -vV | sed -n 's/^host: //p')"
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
toolchain_bin="$toolchain_dir/bin" toolchain_bin="$toolchain_dir/bin"
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
echo "Rust 1.96.1 is not preinstalled at $toolchain_dir." >&2 echo "Rust $toolchain was not installed at $toolchain_dir." >&2
echo "Install it in the Gitea runner image/host before running CI:" >&2
echo "rustup toolchain install 1.96.1 --profile minimal --component clippy --component rustfmt" >&2
exit 1 exit 1
fi fi
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH" printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
@@ -159,7 +214,31 @@ jobs:
- name: Run Playwright e2e - name: Run Playwright e2e
working-directory: apps/ui working-directory: apps/ui
run: npx playwright test run: |
mkdir -p ../../.tmp
rm -f ../../.tmp/openapi-playwright.json ../../.tmp/openapi-ui-evidence.json
PLAYWRIGHT_JSON_OUTPUT=../../.tmp/openapi-playwright.json npx playwright test
- name: Collect sanitized OpenAPI UI evidence
working-directory: apps/ui
run: |
trap 'rm -f ../../.tmp/openapi-playwright.json' EXIT
python3 ../../scripts/collect-capability-baseline.py playwright \
--report ../../.tmp/openapi-playwright.json \
--output ../../.tmp/openapi-ui-evidence.json \
--source-revision "$(git -C ../.. rev-parse HEAD)" \
--environment-class ci \
--flow-id openapi-upload-ui \
--required-test 'operations page imports OpenAPI methods as drafts' \
--required-test 'OpenAPI upload rejects invalid files locally and restores focus after Escape' \
--required-test 'OpenAPI upload recovers from pagehide and a preview server error' \
--required-test 'OpenAPI upload invalidates active draft creation after language or workspace changes' \
--required-test 'OpenAPI upload only renders the latest selected file and clears reset or close races' \
--required-test 'OpenAPI upload ignores a stale failure and renders only correlation identifiers'
python3 ../../scripts/validate-capability-run.py \
--schema ../../docs/schemas/capability-baseline.schema.json \
--candidate ../../.tmp/openapi-ui-evidence.json \
--require-accepted
- name: Show Playwright stack logs - name: Show Playwright stack logs
if: failure() if: failure()
@@ -167,16 +246,84 @@ jobs:
find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true
deployment: deployment:
name: Deployment Manifests name: Community Image Smoke
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: ui needs:
- rust
- ui
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Validate Community deployment manifest - name: Validate Community deployment manifests
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q run: |
docker compose -f docker-compose.yml --env-file .env.example config -q
docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
docker compose -f deploy/community/docker-compose.images.yml --env-file deploy/community/.env.images.example --profile local-db config -q
- name: Build Community images
run: |
docker build -f apps/admin-api/Dockerfile -t crank/admin-api:ci .
docker build -f apps/mcp-server/Dockerfile -t crank/mcp-server:ci .
docker build -f apps/ui/Dockerfile -t crank/ui:ci .
- name: Start Community image stack
run: |
mkdir -p .tmp
cat > .tmp/community-smoke.env <<'EOF'
COMPOSE_PROJECT_NAME=crank-ci-smoke-${{ github.run_id }}-${{ github.run_attempt }}
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_PUBLISH_PORT=0
POSTGRES_DB=crank
POSTGRES_USER=crank
POSTGRES_PASSWORD=crank-ci-password
CRANK_ADMIN_API_IMAGE=crank/admin-api:ci
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:ci
CRANK_UI_IMAGE=crank/ui:ci
CRANK_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
CRANK_SESSION_SECRET=ci-session-secret
CRANK_PASSWORD_PEPPER=ci-password-pepper
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
CRANK_BOOTSTRAP_ADMIN_PASSWORD=ci-admin-password
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=CI Owner
CRANK_BASE_URL=http://127.0.0.1
CRANK_PUBLISH_BIND=127.0.0.1
CRANK_ADMIN_PUBLISH_PORT=0
CRANK_MCP_PUBLISH_PORT=0
CRANK_UI_PUBLISH_PORT=0
CRANK_DEMO_SEED=true
EOF
docker compose -f deploy/community/docker-compose.images.yml \
--env-file .tmp/community-smoke.env --profile local-db up -d --wait
- name: Run authenticated Community image smoke
env:
CRANK_STAGING_ADMIN_EMAIL: owner@crank.test
CRANK_STAGING_ADMIN_PASSWORD: ci-admin-password
run: |
set -eu
project_name="$(sed -n 's/^COMPOSE_PROJECT_NAME=//p' .tmp/community-smoke.env)"
docker run --rm --network "${project_name}_default" \
-e CRANK_STAGING_ADMIN_EMAIL \
-e CRANK_STAGING_ADMIN_PASSWORD \
-i python:3.13-alpine \
python - http://ui:3000 < scripts/authenticated-product-smoke.py
- name: Show Community image logs
if: failure()
run: |
docker compose -f deploy/community/docker-compose.images.yml \
--env-file .tmp/community-smoke.env --profile local-db ps || true
docker compose -f deploy/community/docker-compose.images.yml \
--env-file .tmp/community-smoke.env --profile local-db logs --no-color || true
- name: Stop Community image stack
if: always()
run: |
docker compose -f deploy/community/docker-compose.images.yml \
--env-file .tmp/community-smoke.env --profile local-db down -v --remove-orphans || true
deploy: deploy:
name: Deploy name: Deploy
@@ -233,6 +380,10 @@ jobs:
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \ -t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.UI_IMAGE }}:main' \ -t '${{ env.UI_IMAGE }}:main' \
. .
scripts/scan-images.sh \
'${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
'${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
'${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.ADMIN_API_IMAGE }}:main' docker push '${{ env.ADMIN_API_IMAGE }}:main'
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
@@ -262,9 +413,14 @@ jobs:
run: | run: |
. "$OPENBAO_ENV_FILE" . "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \ ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
"mkdir -p '$DEPLOY_PATH'" "mkdir -p '$DEPLOY_PATH' && \
if [ -f '$DEPLOY_PATH/docker-compose.yml' ]; then \
cp '$DEPLOY_PATH/docker-compose.yml' '$DEPLOY_PATH/docker-compose.previous.yml'; \
fi"
rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \ rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml" "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
rsync -az -e "ssh -p $DEPLOY_PORT" scripts/deploy-community.sh \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/deploy-community.sh"
- name: Write environment file - name: Write environment file
run: | run: |
@@ -280,6 +436,11 @@ jobs:
append_if_set POSTGRES_USER "$POSTGRES_USER" append_if_set POSTGRES_USER "$POSTGRES_USER"
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD" append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
append_if_set POSTGRES_HOST "$POSTGRES_HOST" append_if_set POSTGRES_HOST "$POSTGRES_HOST"
append_if_set POSTGRES_MAX_CONNECTIONS "${POSTGRES_MAX_CONNECTIONS:-}"
append_if_set POSTGRES_MIN_CONNECTIONS "${POSTGRES_MIN_CONNECTIONS:-}"
append_if_set POSTGRES_ACQUIRE_TIMEOUT_MS "${POSTGRES_ACQUIRE_TIMEOUT_MS:-}"
append_if_set POSTGRES_IDLE_TIMEOUT_MS "${POSTGRES_IDLE_TIMEOUT_MS:-}"
append_if_set POSTGRES_MAX_LIFETIME_MS "${POSTGRES_MAX_LIFETIME_MS:-}"
if [ -n "${POSTGRES_PORT:-}" ]; then if [ -n "${POSTGRES_PORT:-}" ]; then
append_if_set POSTGRES_PORT "$POSTGRES_PORT" append_if_set POSTGRES_PORT "$POSTGRES_PORT"
elif [ -n "${PGBOUNCER_PORT:-}" ]; then elif [ -n "${PGBOUNCER_PORT:-}" ]; then
@@ -290,7 +451,35 @@ jobs:
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND" append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND"
append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND" append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND"
append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS" append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS"
append_if_set CRANK_ADMIN_RATE_LIMIT_RPS "${CRANK_ADMIN_RATE_LIMIT_RPS:-}"
append_if_set CRANK_ADMIN_RATE_LIMIT_BURST "${CRANK_ADMIN_RATE_LIMIT_BURST:-}"
append_if_set CRANK_MCP_RATE_LIMIT_RPS "${CRANK_MCP_RATE_LIMIT_RPS:-}"
append_if_set CRANK_MCP_RATE_LIMIT_BURST "${CRANK_MCP_RATE_LIMIT_BURST:-}"
append_if_set CRANK_RUNTIME_MAX_CONCURRENT_UNARY "${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-}"
append_if_set CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS "${CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS:-}"
append_if_set CRANK_OUTBOUND_ALLOWED_HOSTS "${CRANK_OUTBOUND_ALLOWED_HOSTS:-}"
append_if_set CRANK_OUTBOUND_DENIED_HOSTS "${CRANK_OUTBOUND_DENIED_HOSTS:-}"
append_if_set CRANK_OUTBOUND_MAX_RESPONSE_BYTES "${CRANK_OUTBOUND_MAX_RESPONSE_BYTES:-}"
append_if_set CRANK_ENVIRONMENT "${CRANK_ENVIRONMENT:-production}"
append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL" append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL"
append_if_set CRANK_SENTRY_DSN "${CRANK_SENTRY_DSN:-}"
append_if_set CRANK_METRICS_ENABLED "${CRANK_METRICS_ENABLED:-}"
append_if_set CRANK_ADMIN_METRICS_BIND "${CRANK_ADMIN_METRICS_BIND:-}"
append_if_set CRANK_MCP_METRICS_BIND "${CRANK_MCP_METRICS_BIND:-}"
append_if_set CRANK_METRICS_BEARER_TOKEN "${CRANK_METRICS_BEARER_TOKEN:-}"
append_if_set CRANK_INVOCATION_LOG_RETENTION_DAYS "${CRANK_INVOCATION_LOG_RETENTION_DAYS:-}"
append_if_set OTEL_EXPORTER_OTLP_ENDPOINT "${OTEL_EXPORTER_OTLP_ENDPOINT:-}"
append_if_set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT "${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}"
append_if_set OTEL_EXPORTER_OTLP_PROTOCOL "${OTEL_EXPORTER_OTLP_PROTOCOL:-}"
append_if_set OTEL_EXPORTER_OTLP_TRACES_PROTOCOL "${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:-}"
append_if_set OTEL_EXPORTER_OTLP_TIMEOUT "${OTEL_EXPORTER_OTLP_TIMEOUT:-}"
append_if_set OTEL_EXPORTER_OTLP_TRACES_TIMEOUT "${OTEL_EXPORTER_OTLP_TRACES_TIMEOUT:-}"
append_if_set OTEL_EXPORTER_OTLP_HEADERS "${OTEL_EXPORTER_OTLP_HEADERS:-}"
append_if_set OTEL_EXPORTER_OTLP_TRACES_HEADERS "${OTEL_EXPORTER_OTLP_TRACES_HEADERS:-}"
append_if_set OTEL_BSP_MAX_QUEUE_SIZE "${OTEL_BSP_MAX_QUEUE_SIZE:-}"
append_if_set OTEL_BSP_MAX_EXPORT_BATCH_SIZE "${OTEL_BSP_MAX_EXPORT_BATCH_SIZE:-}"
append_if_set OTEL_BSP_SCHEDULE_DELAY "${OTEL_BSP_SCHEDULE_DELAY:-}"
append_if_set OTEL_BSP_EXPORT_TIMEOUT "${OTEL_BSP_EXPORT_TIMEOUT:-}"
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY" append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL" append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND" append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND"
@@ -310,7 +499,10 @@ jobs:
printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}' printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}'
} >> "$tmp_env" } >> "$tmp_env"
cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \ cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
"mkdir -p '$DEPLOY_PATH' && cat > '$DEPLOY_PATH/.env'" "mkdir -p '$DEPLOY_PATH' && \
if [ -f '$DEPLOY_PATH/.env' ]; then \
cp '$DEPLOY_PATH/.env' '$DEPLOY_PATH/.env.previous'; \
fi && cat > '$DEPLOY_PATH/.env'"
rm -f "$tmp_env" rm -f "$tmp_env"
- name: Validate required environment variables - name: Validate required environment variables
@@ -344,48 +536,12 @@ jobs:
- name: Deploy with Docker Compose - name: Deploy with Docker Compose
run: | run: |
. "$OPENBAO_ENV_FILE" . "$OPENBAO_ENV_FILE"
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" " printf '%s' "$DEPLOY_REGISTRY_TOKEN" | ssh -p "$DEPLOY_PORT" \
set -e "$DEPLOY_USER@$DEPLOY_HOST" \
cd '$DEPLOY_PATH' "docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin"
compose_profiles='' ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
cache_backend=\$(grep -E '^CRANK_CACHE_BACKEND=' .env | tail -n1 | cut -d= -f2- || true) "chmod 700 '$DEPLOY_PATH/deploy-community.sh' && \
if [ \"\$cache_backend\" = 'valkey' ] || [ \"\$cache_backend\" = 'redis' ]; then '$DEPLOY_PATH/deploy-community.sh' '$DEPLOY_PATH'"
compose_profiles='--profile cache'
fi
echo '$DEPLOY_REGISTRY_TOKEN' | docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin
docker compose \$compose_profiles config -q
docker compose \$compose_profiles pull
docker compose \$compose_profiles down --remove-orphans
for container in \
crank-ui-1 \
crank-admin-api-1 \
crank-mcp-server-1 \
crank-postgres-1 \
crank-valkey-1 \
crank-community-ui-1 \
crank-community-admin-api-1 \
crank-community-mcp-server-1 \
crank-community-postgres-1 \
crank-community-valkey-1; do
if docker ps -a --format '{{.Names}}' | grep -Fx \"\$container\" >/dev/null; then
docker rm -f \"\$container\"
fi
done
echo 'Docker containers before freeing required ports:'
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Ports}}'
for port in 3000 3001 3002; do
container_ids=\$(docker ps -aq --filter \"publish=\$port\")
if [ -n \"\$container_ids\" ]; then
echo \"Removing containers publishing port \$port\"
docker inspect --format '{{.Name}} {{json .NetworkSettings.Ports}}' \$container_ids || true
docker rm -f \$container_ids
fi
done
if command -v ss >/dev/null 2>&1; then
ss -ltnp '( sport = :3000 or sport = :3001 or sport = :3002 )' || true
fi
docker compose \$compose_profiles up -d --remove-orphans
"
- name: Verify health endpoints - name: Verify health endpoints
run: | run: |
@@ -395,8 +551,8 @@ jobs:
cd '$DEPLOY_PATH' cd '$DEPLOY_PATH'
for attempt in \$(seq 1 30); do for attempt in \$(seq 1 30); do
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \ if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \
&& curl --fail --silent http://127.0.0.1:3001/health >/dev/null \ && curl --fail --silent http://127.0.0.1:3001/ready >/dev/null \
&& curl --fail --silent http://127.0.0.1:3002/health >/dev/null; then && curl --fail --silent http://127.0.0.1:3002/ready >/dev/null; then
exit 0 exit 0
fi fi
sleep 2 sleep 2
+129 -1
View File
@@ -41,6 +41,7 @@ jobs:
- name: Verify runner toolchain - name: Verify runner toolchain
run: | run: |
python3 --version
rustc --version rustc --version
cargo --version cargo --version
node --version node --version
@@ -49,6 +50,56 @@ jobs:
command -v bao command -v bao
bao version bao version
- name: Install dependency policy tool
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Run tooling unit tests
run: python3 -m unittest discover -s tests/unit
- name: Check typed runtime configuration contract
run: |
cargo run -p crank-config --bin crank-config-contract -- --check
python3 scripts/check-runtime-config.py --root .
python3 scripts/check-config-boundaries.py --root .
scripts/check-rust-boundaries.sh
- name: Check canonical migration contract
run: cargo run -p admin-api --bin crank-migrate -- plan --check
- name: Check typed metrics contract
run: |
cargo run -p crank-metrics --bin crank-metrics-contract -- --check
python3 scripts/check-metrics-boundaries.py --root .
- name: Check Capability Inventory
run: |
required_args=""
for number in $(seq 1 54); do
required_args="$required_args --required-fr FR-$number"
done
python3 scripts/validate-capability-inventory.py \
--root . \
--inventory docs/capability-inventory.json \
--schema docs/schemas/capability-inventory.schema.json \
$required_args
- name: Check Capability Baseline
run: |
python3 scripts/validate-capability-baseline.py \
--root . \
--manifest docs/capability-baseline/manifest.json \
--schema docs/schemas/capability-baseline.schema.json
- name: Check Community scope
run: scripts/check-community-scope.sh
- name: Run release quality gates
run: |
cargo fmt --all --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets -- --test-threads=1
cargo deny --locked check advisories bans licenses sources
- name: Build release binaries - name: Build release binaries
run: cargo build --release -p admin-api -p mcp-server run: cargo build --release -p admin-api -p mcp-server
@@ -56,22 +107,69 @@ jobs:
working-directory: apps/ui working-directory: apps/ui
run: npm ci run: npm ci
- name: Audit UI dependencies
working-directory: apps/ui
run: npm audit --audit-level=high
- name: Build UI dist - name: Build UI dist
working-directory: apps/ui working-directory: apps/ui
run: npm run build run: npm run build
- name: Install Playwright browser
working-directory: apps/ui
run: npx playwright install --with-deps chromium
- name: Run release end-to-end tests
working-directory: apps/ui
run: |
mkdir -p ../../.tmp
rm -f ../../.tmp/openapi-playwright.json ../../.tmp/openapi-ui-evidence.json
PLAYWRIGHT_JSON_OUTPUT=../../.tmp/openapi-playwright.json npm run e2e
- name: Collect sanitized OpenAPI UI release evidence
working-directory: apps/ui
run: |
trap 'rm -f ../../.tmp/openapi-playwright.json' EXIT
python3 ../../scripts/collect-capability-baseline.py playwright \
--report ../../.tmp/openapi-playwright.json \
--output ../../.tmp/openapi-ui-evidence.json \
--source-revision "$(git -C ../.. rev-parse HEAD)" \
--environment-class release \
--flow-id openapi-upload-ui \
--required-test 'operations page imports OpenAPI methods as drafts' \
--required-test 'OpenAPI upload rejects invalid files locally and restores focus after Escape' \
--required-test 'OpenAPI upload recovers from pagehide and a preview server error' \
--required-test 'OpenAPI upload invalidates active draft creation after language or workspace changes' \
--required-test 'OpenAPI upload only renders the latest selected file and clears reset or close races' \
--required-test 'OpenAPI upload ignores a stale failure and renders only correlation identifiers'
python3 ../../scripts/validate-capability-run.py \
--schema ../../docs/schemas/capability-baseline.schema.json \
--candidate ../../.tmp/openapi-ui-evidence.json \
--require-accepted
- name: Validate deployment manifests
run: |
docker compose -f docker-compose.yml --env-file .env.example config -q
docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
docker compose -f deploy/community/docker-compose.images.yml --env-file deploy/community/.env.images.example --profile local-db config -q
- name: Package release artifacts - name: Package release artifacts
run: | run: |
mkdir -p dist/release mkdir -p dist/release
source_revision="$(git rev-parse HEAD)"
evidence_artifact="dist/crank-openapi-ui-evidence-${IMAGE_TAG}-${source_revision}.json"
cp target/release/admin-api dist/release/admin-api cp target/release/admin-api dist/release/admin-api
cp target/release/crank-migrate dist/release/crank-migrate
cp target/release/mcp-server dist/release/mcp-server cp target/release/mcp-server dist/release/mcp-server
tar -C dist/release -czf dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz admin-api cp .tmp/openapi-ui-evidence.json "$evidence_artifact"
tar -C dist/release -czf dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz admin-api crank-migrate
tar -C dist/release -czf dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz mcp-server tar -C dist/release -czf dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz mcp-server
tar -C apps/ui/dist -czf dist/crank-community-ui-${IMAGE_TAG}.tar.gz . tar -C apps/ui/dist -czf dist/crank-community-ui-${IMAGE_TAG}.tar.gz .
sha256sum \ sha256sum \
dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz \ dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz \
dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz \ dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz \
dist/crank-community-ui-${IMAGE_TAG}.tar.gz \ dist/crank-community-ui-${IMAGE_TAG}.tar.gz \
"$evidence_artifact" \
> dist/crank-community-${IMAGE_TAG}-checksums.txt > dist/crank-community-${IMAGE_TAG}-checksums.txt
- name: Generate SBOM - name: Generate SBOM
@@ -105,6 +203,36 @@ jobs:
docker build -f apps/ui/Dockerfile \ docker build -f apps/ui/Dockerfile \
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \ -t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
-t '${{ env.UI_IMAGE }}:latest' . -t '${{ env.UI_IMAGE }}:latest' .
mkdir -p .tmp
cat > .tmp/release-migration-smoke.env <<EOF
COMPOSE_PROJECT_NAME=crank-release-migration-${{ github.run_id }}-${{ github.run_attempt }}
POSTGRES_HOST=postgres
POSTGRES_DB=crank
POSTGRES_USER=crank
POSTGRES_PASSWORD=release-smoke-password
CRANK_ADMIN_API_IMAGE=${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}
CRANK_MCP_SERVER_IMAGE=${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}
CRANK_UI_IMAGE=${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}
CRANK_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
CRANK_SESSION_SECRET=release-smoke-session
CRANK_PASSWORD_PEPPER=release-smoke-pepper
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
CRANK_BOOTSTRAP_ADMIN_PASSWORD=release-smoke-password
CRANK_BASE_URL=http://127.0.0.1
CRANK_PUBLISH_BIND=127.0.0.1
CRANK_ADMIN_PUBLISH_PORT=0
CRANK_MCP_PUBLISH_PORT=0
CRANK_UI_PUBLISH_PORT=0
EOF
trap 'docker compose -f deploy/community/docker-compose.images.yml --env-file .tmp/release-migration-smoke.env --profile local-db down -v --remove-orphans || true' EXIT
docker compose -f deploy/community/docker-compose.images.yml \
--env-file .tmp/release-migration-smoke.env --profile local-db up -d --wait
docker compose -f deploy/community/docker-compose.images.yml \
--env-file .tmp/release-migration-smoke.env --profile local-db logs migrate | grep '"status":"applied"'
scripts/scan-images.sh \
'${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
'${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
'${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
docker push '${{ env.ADMIN_API_IMAGE }}:latest' docker push '${{ env.ADMIN_API_IMAGE }}:latest'
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
+7
View File
@@ -23,3 +23,10 @@ __*.md
diploma/ diploma/
AGENTS.md AGENTS.md
TASKS.md TASKS.md
# BMAD workspace data and generated artifacts
**/_bmad*/
.bmad-loop/runs/
.bmad-loop/cache/
.bmad-loop/policy.toml
_bmad/render/
@@ -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"
}
@@ -6,72 +6,156 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "name", "name": "name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "name"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "display_name"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "category", "name": "category",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "category"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "protocol", "name": "protocol",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "protocol"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "security_level", "name": "security_level",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "security_level"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "target_json", "name": "target_json",
"type_info": "Jsonb" "type_info": "Jsonb",
"origin": {
"Table": {
"table": "operation_versions",
"name": "target_json"
}
}
}, },
{ {
"ordinal": 8, "ordinal": 8,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "status"
}
}
}, },
{ {
"ordinal": 9, "ordinal": 9,
"name": "current_draft_version", "name": "current_draft_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "operations",
"name": "current_draft_version"
}
}
}, },
{ {
"ordinal": 10, "ordinal": 10,
"name": "latest_published_version", "name": "latest_published_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "operations",
"name": "latest_published_version"
}
}
}, },
{ {
"ordinal": 11, "ordinal": 11,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "operations",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 12, "ordinal": 12,
"name": "updated_at!: time::OffsetDateTime", "name": "updated_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "operations",
"name": "updated_at"
}
}
}, },
{ {
"ordinal": 13, "ordinal": 13,
"name": "published_at: time::OffsetDateTime", "name": "published_at: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "operations",
"name": "published_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -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"
}
@@ -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"
}
@@ -6,32 +6,68 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "agent_id", "name": "agent_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agent_versions",
"name": "agent_id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "version", "name": "version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "agent_versions",
"name": "version"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agent_versions",
"name": "status"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "instructions_json", "name": "instructions_json",
"type_info": "Jsonb" "type_info": "Jsonb",
"origin": {
"Table": {
"table": "agent_versions",
"name": "instructions_json"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "tool_selection_policy_json", "name": "tool_selection_policy_json",
"type_info": "Jsonb" "type_info": "Jsonb",
"origin": {
"Table": {
"table": "agent_versions",
"name": "tool_selection_policy_json"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "agent_versions",
"name": "created_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,42 +6,90 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operation_samples",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "operation_id", "name": "operation_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operation_samples",
"name": "operation_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "version", "name": "version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "operation_samples",
"name": "version"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "sample_kind", "name": "sample_kind",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operation_samples",
"name": "sample_kind"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "storage_ref", "name": "storage_ref",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operation_samples",
"name": "storage_ref"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "content_type", "name": "content_type",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operation_samples",
"name": "content_type"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "file_name", "name": "file_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operation_samples",
"name": "file_name"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "operation_samples",
"name": "created_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,57 +6,123 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "slug", "name": "slug",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "slug"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "display_name"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "description", "name": "description",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "description"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "status"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "current_draft_version", "name": "current_draft_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "agents",
"name": "current_draft_version"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "latest_published_version", "name": "latest_published_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "agents",
"name": "latest_published_version"
}
}
}, },
{ {
"ordinal": 8, "ordinal": 8,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "agents",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 9, "ordinal": 9,
"name": "updated_at!: time::OffsetDateTime", "name": "updated_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "agents",
"name": "updated_at"
}
}
}, },
{ {
"ordinal": 10, "ordinal": 10,
"name": "published_at: time::OffsetDateTime", "name": "published_at: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "agents",
"name": "published_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -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"
}
@@ -6,27 +6,57 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "email", "name": "email",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "email"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "display_name"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "status"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "created_at!: OffsetDateTime", "name": "created_at!: OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "users",
"name": "created_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,7 +6,8 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "allowed!", "name": "allowed!",
"type_info": "Bool" "type_info": "Bool",
"origin": "Expression"
} }
], ],
"parameters": { "parameters": {
@@ -6,7 +6,8 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "present!", "name": "present!",
"type_info": "Int4" "type_info": "Int4",
"origin": "Expression"
} }
], ],
"parameters": { "parameters": {
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -6,37 +6,79 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "workspaces",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "slug", "name": "slug",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "workspaces",
"name": "slug"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "workspaces",
"name": "display_name"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "workspaces",
"name": "status"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "settings_json", "name": "settings_json",
"type_info": "Jsonb" "type_info": "Jsonb",
"origin": {
"Table": {
"table": "workspaces",
"name": "settings_json"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "workspaces",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "updated_at!: time::OffsetDateTime", "name": "updated_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "workspaces",
"name": "updated_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,42 +6,90 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "descriptors",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "operation_id", "name": "operation_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "descriptors",
"name": "operation_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "version", "name": "version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "descriptors",
"name": "version"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "descriptor_kind", "name": "descriptor_kind",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "descriptors",
"name": "descriptor_kind"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "storage_ref", "name": "storage_ref",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "descriptors",
"name": "storage_ref"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "source_name", "name": "source_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "descriptors",
"name": "source_name"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "package_index_json", "name": "package_index_json",
"type_info": "Jsonb" "type_info": "Jsonb",
"origin": {
"Table": {
"table": "descriptors",
"name": "package_index_json"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "descriptors",
"name": "created_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,7 +6,8 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "present!", "name": "present!",
"type_info": "Int4" "type_info": "Int4",
"origin": "Expression"
} }
], ],
"parameters": { "parameters": {
@@ -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"
}
@@ -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"
}
@@ -6,57 +6,123 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "slug", "name": "slug",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "slug"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "display_name"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "description", "name": "description",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "description"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "status"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "current_draft_version", "name": "current_draft_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "agents",
"name": "current_draft_version"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "latest_published_version", "name": "latest_published_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "agents",
"name": "latest_published_version"
}
}
}, },
{ {
"ordinal": 8, "ordinal": 8,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "agents",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 9, "ordinal": 9,
"name": "updated_at!: time::OffsetDateTime", "name": "updated_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "agents",
"name": "updated_at"
}
}
}, },
{ {
"ordinal": 10, "ordinal": 10,
"name": "published_at: time::OffsetDateTime", "name": "published_at: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "agents",
"name": "published_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -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"
}
@@ -6,37 +6,79 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "name", "name": "name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "name"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "kind", "name": "kind",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "kind"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "config_json", "name": "config_json",
"type_info": "Jsonb" "type_info": "Jsonb",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "config_json"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "created_at!: OffsetDateTime", "name": "created_at!: OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "updated_at!: OffsetDateTime", "name": "updated_at!: OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "updated_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -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"
}
@@ -6,42 +6,90 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "memberships",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "user_id", "name": "user_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "memberships",
"name": "user_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "role", "name": "role",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "memberships",
"name": "role"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "memberships",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "email", "name": "email",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "email"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "display_name"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "status"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "user_created_at!: time::OffsetDateTime", "name": "user_created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "users",
"name": "created_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -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"
}
@@ -6,47 +6,101 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "name", "name": "name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "name"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "kind", "name": "kind",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "kind"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "status"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "current_version", "name": "current_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "secrets",
"name": "current_version"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "secrets",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "updated_at!: time::OffsetDateTime", "name": "updated_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "secrets",
"name": "updated_at"
}
}
}, },
{ {
"ordinal": 8, "ordinal": 8,
"name": "last_used_at: time::OffsetDateTime", "name": "last_used_at: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "secrets",
"name": "last_used_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,37 +6,79 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "workspaces",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "slug", "name": "slug",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "workspaces",
"name": "slug"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "workspaces",
"name": "display_name"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "workspaces",
"name": "status"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "settings_json", "name": "settings_json",
"type_info": "Jsonb" "type_info": "Jsonb",
"origin": {
"Table": {
"table": "workspaces",
"name": "settings_json"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "workspaces",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "updated_at!: time::OffsetDateTime", "name": "updated_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "workspaces",
"name": "updated_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,47 +6,101 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "name", "name": "name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "name"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "kind", "name": "kind",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "kind"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secrets",
"name": "status"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "current_version", "name": "current_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "secrets",
"name": "current_version"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "secrets",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "updated_at!: time::OffsetDateTime", "name": "updated_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "secrets",
"name": "updated_at"
}
}
}, },
{ {
"ordinal": 8, "ordinal": 8,
"name": "last_used_at: time::OffsetDateTime", "name": "last_used_at: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "secrets",
"name": "last_used_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,42 +6,90 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "invitation_tokens",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "invitation_tokens",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "email", "name": "email",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "invitation_tokens",
"name": "email"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "role", "name": "role",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "invitation_tokens",
"name": "role"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "invitation_tokens",
"name": "status"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "token_hash", "name": "token_hash",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "invitation_tokens",
"name": "token_hash"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "expires_at!: time::OffsetDateTime", "name": "expires_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "invitation_tokens",
"name": "expires_at"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "invitation_tokens",
"name": "created_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,72 +6,156 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "name", "name": "name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "name"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "display_name"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "category", "name": "category",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "category"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "protocol", "name": "protocol",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "protocol"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "security_level", "name": "security_level",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "security_level"
}
}
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "target_json", "name": "target_json",
"type_info": "Jsonb" "type_info": "Jsonb",
"origin": {
"Table": {
"table": "operation_versions",
"name": "target_json"
}
}
}, },
{ {
"ordinal": 8, "ordinal": 8,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "operations",
"name": "status"
}
}
}, },
{ {
"ordinal": 9, "ordinal": 9,
"name": "current_draft_version", "name": "current_draft_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "operations",
"name": "current_draft_version"
}
}
}, },
{ {
"ordinal": 10, "ordinal": 10,
"name": "latest_published_version", "name": "latest_published_version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "operations",
"name": "latest_published_version"
}
}
}, },
{ {
"ordinal": 11, "ordinal": 11,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "operations",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 12, "ordinal": 12,
"name": "updated_at!: time::OffsetDateTime", "name": "updated_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "operations",
"name": "updated_at"
}
}
}, },
{ {
"ordinal": 13, "ordinal": 13,
"name": "published_at: time::OffsetDateTime", "name": "published_at: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "operations",
"name": "published_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,37 +6,79 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "user_sessions",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "user_id", "name": "user_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "user_sessions",
"name": "user_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "current_workspace_id", "name": "current_workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "user_sessions",
"name": "current_workspace_id"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "email", "name": "email",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "email"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "display_name"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "status", "name": "status",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "users",
"name": "status"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "created_at!: OffsetDateTime", "name": "created_at!: OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "users",
"name": "created_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,37 +6,79 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "id", "name": "id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "workspace_id", "name": "workspace_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "workspace_id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "name", "name": "name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "name"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "kind", "name": "kind",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "kind"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "config_json", "name": "config_json",
"type_info": "Jsonb" "type_info": "Jsonb",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "config_json"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "created_at!: OffsetDateTime", "name": "created_at!: OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "updated_at!: OffsetDateTime", "name": "updated_at!: OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "auth_profiles",
"name": "updated_at"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,32 +6,68 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "secret_id", "name": "secret_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secret_versions",
"name": "secret_id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "version", "name": "version",
"type_info": "Int4" "type_info": "Int4",
"origin": {
"Table": {
"table": "secret_versions",
"name": "version"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "ciphertext", "name": "ciphertext",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secret_versions",
"name": "ciphertext"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "key_version", "name": "key_version",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secret_versions",
"name": "key_version"
}
}
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "created_at!: time::OffsetDateTime", "name": "created_at!: time::OffsetDateTime",
"type_info": "Timestamptz" "type_info": "Timestamptz",
"origin": {
"Table": {
"table": "secret_versions",
"name": "created_at"
}
}
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "created_by", "name": "created_by",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "secret_versions",
"name": "created_by"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -6,22 +6,46 @@
{ {
"ordinal": 0, "ordinal": 0,
"name": "operation_id", "name": "operation_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agent_operation_bindings",
"name": "operation_id"
}
}
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "agent_id", "name": "agent_id",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "id"
}
}
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "agent_slug", "name": "agent_slug",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "slug"
}
}
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "display_name", "name": "display_name",
"type_info": "Text" "type_info": "Text",
"origin": {
"Table": {
"table": "agents",
"name": "display_name"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -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"
}
+17
View File
@@ -30,10 +30,25 @@ npx playwright test
## Требования к изменениям ## Требования к изменениям
- Не добавляйте функциональность вне границ Community-версии. - Не добавляйте функциональность вне границ Community-версии.
- Сверяйте границу и статус flow с
[`docs/capability-inventory.json`](./docs/capability-inventory.json): только
`implemented` считается готовым, а `planned`, `gap` и `blocked` не являются
разрешением заявить незавершённую функцию работающей.
- Если change меняет capability status или evidence, обновите versioned
[`docs/capability-baseline/manifest.json`](./docs/capability-baseline/manifest.json),
sanitized results и SHA-256. Нельзя вручную объявлять pass для failed, flaky,
skipped, not-run или manual-only evidence.
- Не добавляйте секреты, токены, приватные адреса и локальные настройки. - Не добавляйте секреты, токены, приватные адреса и локальные настройки.
- Не коммитьте `AGENTS.md`, `TASKS.md`, `.env` и временные файлы. - Не коммитьте `AGENTS.md`, `TASKS.md`, `.env` и временные файлы.
- Для изменений SQL-запросов обновляйте `.sqlx`, если это требуется SQLx. - Для изменений SQL-запросов обновляйте `.sqlx`, если это требуется SQLx.
- Изменение PostgreSQL schema начинается с новой append-only migration в
`crank-registry`; baseline v1 не редактируется. Выполните
`just migration-contract-check` и PostgreSQL migration integration suite.
- Для пользовательских изменений обновляйте документацию или примеры. - Для пользовательских изменений обновляйте документацию или примеры.
- Runtime environment contract изменяется только через registry
`crates/crank-config`. После изменения выполните
`just config-contract-check`; generated sections `.env.example`, Compose и
parameter reference не поддерживаются независимыми ручными таблицами.
## Границы проекта ## Границы проекта
@@ -47,3 +62,5 @@ npx playwright test
- необязательный Valkey или Redis для служебного кэша. - необязательный Valkey или Redis для служебного кэша.
Функции за пределами перечисленного набора не должны попадать в этот репозиторий. Функции за пределами перечисленного набора не должны попадать в этот репозиторий.
Целевые Resources, Prompts, Tasks и Load Runs допускаются только через
проверяемое изменение Inventory, guardrails, tests и документации.
Generated
+942 -59
View File
File diff suppressed because it is too large Load Diff
+33 -2
View File
@@ -4,14 +4,19 @@ members = [
"apps/mcp-server", "apps/mcp-server",
"crates/crank-community-auth", "crates/crank-community-auth",
"crates/crank-community-mcp", "crates/crank-community-mcp",
"crates/crank-config",
"crates/crank-core", "crates/crank-core",
"crates/crank-import", "crates/crank-import",
"crates/crank-schema", "crates/crank-schema",
"crates/crank-mapping", "crates/crank-mapping",
"crates/crank-metrics",
"crates/crank-observability",
"crates/crank-registry", "crates/crank-registry",
"crates/crank-runtime", "crates/crank-runtime",
"crates/crank-test-support", "crates/crank-test-support",
"crates/crank-trace",
"crates/crank-adapter-rest", "crates/crank-adapter-rest",
"crates/crank-artifacts",
] ]
resolver = "3" resolver = "3"
@@ -20,26 +25,52 @@ edition = "2024"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
rust-version = "1.96" rust-version = "1.96"
version = "0.3.1" version = "0.3.1"
publish = false
[workspace.dependencies] [workspace.dependencies]
aes-gcm = "0.10" aes-gcm = "0.10"
argon2 = "0.5" argon2 = "0.5"
axum = "0.8" axum = { version = "0.8", features = ["multipart"] }
axum-extra = { version = "0.12", features = ["cookie"] } axum-extra = { version = "0.12", features = ["cookie"] }
base64 = "0.22" base64 = "0.22"
hkdf = "0.12" hkdf = "0.12"
metrics = "0.24.6"
metrics-exporter-prometheus = { version = "0.18.3", default-features = false }
opentelemetry = { version = "0.32.0", default-features = false, features = ["trace"] }
opentelemetry-otlp = { version = "0.32.0", default-features = false, features = ["http-proto", "reqwest-blocking-client", "reqwest-rustls", "trace"] }
opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["gen-tonic-messages", "trace"] }
opentelemetry_sdk = { version = "0.32.1", default-features = false, features = ["trace"] }
percent-encoding = "2"
prost = "0.14"
rand = "0.10" rand = "0.10"
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "multipart", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
sentry = { version = "0.49.0", default-features = false, features = ["backtrace", "panic", "rustls", "ureq"] }
sha2 = "0.10" sha2 = "0.10"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "json", "time", "uuid"] } sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "json", "time", "uuid"] }
subtle = "2.6"
thiserror = "2" thiserror = "2"
time = { version = "0.3.53", features = ["formatting", "parsing", "serde"] } time = { version = "0.3.53", features = ["formatting", "parsing", "serde"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tower = "0.5"
tracing = "0.1" tracing = "0.1"
tracing-opentelemetry = { version = "0.33.0", default-features = false }
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2"
uuid = { version = "1", features = ["serde", "v7"] } uuid = { version = "1", features = ["serde", "v7"] }
testcontainers = { version = "0.27", features = ["blocking"] } testcontainers = { version = "0.27", features = ["blocking"] }
testcontainers-modules = { version = "0.15", features = ["postgres", "blocking"] } testcontainers-modules = { version = "0.15", features = ["postgres", "blocking"] }
[profile.dev]
debug = "line-tables-only"
[profile.dev.package."*"]
debug = false
[profile.test]
debug = "line-tables-only"
[profile.test.package."*"]
debug = false
+17
View File
@@ -165,6 +165,20 @@ docker compose up -d --build
- PostgreSQL как основное хранилище; - PostgreSQL как основное хранилище;
- необязательный Valkey или Redis для служебного кэша. - необязательный Valkey или Redis для служебного кэша.
Текущие и целевые возможности фиксируются в
[`docs/capability-inventory.json`](docs/capability-inventory.json). Статус
`implemented` означает реализованный flow; `planned`, `gap` и `blocked` не
считаются готовностью. MCP Resources, Prompts, фоновые Tasks и встроенные Load
Runs сейчас перечислены только как planned target и не выдаются за работающие
возможности этой версии.
Проверенный snapshot текущих UI, Admin API и MCP flows находится в
[`docs/capability-baseline/manifest.json`](docs/capability-baseline/manifest.json).
Он связывает inventory, обязательные surfaces, taxonomy, manual checklist и
sanitized results точными SHA-256. Полный baseline pass требует сочетания
`implemented + automated + pass`; skipped, flaky, not-run и manual-only не
становятся pass.
## Структура проекта ## Структура проекта
```text ```text
@@ -268,7 +282,10 @@ npx playwright test
- [Production checklist](docs/production-checklist.md) - [Production checklist](docs/production-checklist.md)
- [Troubleshooting](docs/troubleshooting.md) - [Troubleshooting](docs/troubleshooting.md)
- [Настройки запуска](docs/runtime-config.md) - [Настройки запуска](docs/runtime-config.md)
- [Machine schema runtime-конфигурации](docs/schemas/runtime-config.schema.json)
- [Английский README](docs/en/README.md) - [Английский README](docs/en/README.md)
- [Capability Inventory](docs/capability-inventory.json)
- [Capability Baseline](docs/capability-baseline/manifest.json)
## Участие в разработке ## Участие в разработке
+20 -2
View File
@@ -3,24 +3,35 @@ name = "admin-api"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish.workspace = true
version.workspace = true version.workspace = true
[[bin]] [[bin]]
name = "admin-api" name = "admin-api"
path = "src/main.rs" path = "src/main.rs"
[[bin]]
name = "crank-migrate"
path = "src/bin/crank-migrate.rs"
[dependencies] [dependencies]
async-trait = "0.1"
argon2.workspace = true argon2.workspace = true
axum.workspace = true axum.workspace = true
axum-extra.workspace = true axum-extra.workspace = true
base64.workspace = true base64.workspace = true
crank-community-auth = { path = "../../crates/crank-community-auth" } crank-community-auth = { path = "../../crates/crank-community-auth" }
crank-artifacts = { path = "../../crates/crank-artifacts" }
crank-config = { path = "../../crates/crank-config" }
crank-core = { path = "../../crates/crank-core" } crank-core = { path = "../../crates/crank-core" }
crank-import = { path = "../../crates/crank-import" } crank-import = { path = "../../crates/crank-import" }
crank-mapping = { path = "../../crates/crank-mapping" } crank-mapping = { path = "../../crates/crank-mapping" }
crank-metrics = { path = "../../crates/crank-metrics" }
crank-observability = { path = "../../crates/crank-observability" }
crank-registry = { path = "../../crates/crank-registry" } crank-registry = { path = "../../crates/crank-registry" }
crank-runtime = { path = "../../crates/crank-runtime" } crank-runtime = { path = "../../crates/crank-runtime" }
crank-schema = { path = "../../crates/crank-schema" } crank-schema = { path = "../../crates/crank-schema" }
crank-trace = { path = "../../crates/crank-trace" }
rand.workspace = true rand.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
@@ -29,13 +40,20 @@ sha2.workspace = true
sqlx.workspace = true sqlx.workspace = true
thiserror.workspace = true thiserror.workspace = true
time.workspace = true time.workspace = true
tokio = { workspace = true, features = ["fs"] } tokio = { workspace = true, features = ["fs", "time"] }
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
url.workspace = true
uuid.workspace = true uuid.workspace = true
[dev-dependencies] [dev-dependencies]
async-trait = "0.1" crank-community-mcp = { path = "../../crates/crank-community-mcp" }
crank-test-support = { path = "../../crates/crank-test-support" } crank-test-support = { path = "../../crates/crank-test-support" }
metrics.workspace = true
metrics-util = "0.20.4"
opentelemetry.workspace = true
opentelemetry_sdk.workspace = true
reqwest.workspace = true reqwest.workspace = true
serial_test = "3" serial_test = "3"
tower.workspace = true
tracing-opentelemetry.workspace = true
+19 -44
View File
@@ -1,41 +1,3 @@
FROM rust:1.96.1-bookworm AS deps
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY .sqlx ./.sqlx
COPY apps/admin-api/Cargo.toml apps/admin-api/Cargo.toml
COPY apps/mcp-server/Cargo.toml apps/mcp-server/Cargo.toml
COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml
COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml
COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml
COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml
COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml
COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml
RUN mkdir -p \
apps/admin-api/src \
apps/mcp-server/src \
crates/crank-core/src \
crates/crank-schema/src \
crates/crank-mapping/src \
crates/crank-registry/src \
crates/crank-runtime/src \
crates/crank-adapter-rest/src \
&& printf 'fn main() {}\n' > apps/admin-api/src/main.rs \
&& printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/app/target \
SQLX_OFFLINE=true cargo build --release -p admin-api
FROM rust:1.96.1-bookworm AS builder FROM rust:1.96.1-bookworm AS builder
WORKDIR /app WORKDIR /app
@@ -45,24 +7,37 @@ COPY .sqlx ./.sqlx
COPY apps ./apps COPY apps ./apps
COPY crates ./crates COPY crates ./crates
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,id=crank-admin-cargo-registry,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git/db \ --mount=type=cache,id=crank-admin-cargo-git,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/app/target \ --mount=type=cache,id=crank-admin-target,target=/app/target \
SQLX_OFFLINE=true cargo build --release -p admin-api \ SQLX_OFFLINE=true cargo build --release -p admin-api \
&& cp /app/target/release/admin-api /tmp/admin-api && cp /app/target/release/admin-api /tmp/admin-api \
&& cp /app/target/release/crank-migrate /tmp/crank-migrate
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update \ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
&& apt-get install -y --no-install-recommends ca-certificates curl \
RUN test -s /etc/ssl/certs/ca-certificates.crt \
&& sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
&& grep -q '^URIs: https://deb.debian.org/' /etc/apt/sources.list.d/debian.sources \
&& ! grep -Eq '^[[:space:]]*URIs:[[:space:]]+http://' /etc/apt/sources.list.d/debian.sources \
&& apt-get -o Acquire::Retries=3 update \
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api
COPY --from=builder /tmp/crank-migrate /usr/local/bin/crank-migrate
COPY apps/admin-api/docker-entrypoint.sh /usr/local/bin/crank-admin-entrypoint
RUN chmod 0755 /usr/local/bin/crank-admin-entrypoint
ENV CRANK_ADMIN_BIND=0.0.0.0:3001 ENV CRANK_ADMIN_BIND=0.0.0.0:3001
ENV CRANK_STORAGE_ROOT=/var/lib/crank/storage
EXPOSE 3001 EXPOSE 3001
ENTRYPOINT ["/usr/local/bin/crank-admin-entrypoint"]
CMD ["admin-api"] CMD ["admin-api"]
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
set -eu
# Docker creates a fresh named volume as root:root 0755. The artifact store
# deliberately rejects that mode: before starting the API, provision exactly
# the private root its pinned-directory checks require.
if [ "$#" -gt 0 ] && [ "$1" = "admin-api" ]; then
storage_root="${CRANK_STORAGE_ROOT:-/var/lib/crank/storage}"
case "$storage_root" in
/*) ;;
*)
echo "CRANK_STORAGE_ROOT must be an absolute path" >&2
exit 64
;;
esac
if [ -L "$storage_root" ] || { [ -e "$storage_root" ] && [ ! -d "$storage_root" ]; }; then
echo "CRANK_STORAGE_ROOT must be a directory, not a symlink or file" >&2
exit 64
fi
umask 077
mkdir -p -- "$storage_root"
chmod 0700 -- "$storage_root"
fi
exec "$@"
+52 -9
View File
@@ -1,10 +1,16 @@
use axum::{ use axum::{
Router, middleware, Router,
extract::DefaultBodyLimit,
middleware,
routing::{delete, get, post}, routing::{delete, get, post},
}; };
use crank_artifacts::MAX_ARTIFACT_BYTES;
use crate::{ 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, rate_limit::apply_api_rate_limit,
request_context::apply_request_context, request_context::apply_request_context,
routes::{ routes::{
@@ -12,17 +18,21 @@ use crate::{
agents::{ agents::{
archive_agent, create_agent, create_agent_platform_api_key, delete_agent, archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
delete_agent_platform_api_key, get_agent, get_agent_version, delete_agent_platform_api_key, get_agent, get_agent_version,
list_agent_platform_api_keys, list_agents, publish_agent, list_agent_platform_api_keys, list_agents, preview_tool_search, publish_agent,
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_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}, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
capabilities::get_capabilities, capabilities::get_capabilities,
imports::{create_openapi_import, preview_openapi_import}, imports::{create_openapi_import, preview_openapi_import},
observability::{ observability::{
get_agent_usage, get_approval, get_log, get_operation_usage, get_usage, list_approvals, approve_approval, deny_approval, export_logs_csv, export_usage_csv, get_agent_usage,
list_logs, get_approval, get_log, get_operation_usage, get_usage, list_approvals, list_logs,
}, },
onboarding::{get_onboarding, record_onboarding_event, reset_onboarding_selection},
operations::{ operations::{
analyze_operation_quality, archive_operation, create_operation, create_version, analyze_operation_quality, archive_operation, create_operation, create_version,
delete_operation, export_operation, generate_draft, get_operation, delete_operation, export_operation, generate_draft, get_operation,
@@ -39,7 +49,11 @@ use crate::{
pub fn build_app(state: AppState) -> Router { pub fn build_app(state: AppState) -> Router {
let workspace_router = Router::new() let workspace_router = Router::new()
.route("/operations", get(list_operations).post(create_operation)) .route("/operations", get(list_operations).post(create_operation))
.route("/imports/openapi/preview", post(preview_openapi_import)) .route(
"/imports/openapi/preview",
post(preview_openapi_import)
.layer(DefaultBodyLimit::max(MAX_ARTIFACT_BYTES + 32 * 1024)),
)
.route( .route(
"/imports/openapi/{job_id}/create", "/imports/openapi/{job_id}/create",
post(create_openapi_import), post(create_openapi_import),
@@ -48,7 +62,10 @@ pub fn build_app(state: AppState) -> Router {
"/operations/analyze-quality", "/operations/analyze-quality",
post(analyze_operation_quality), post(analyze_operation_quality),
) )
.route("/operations/import", post(import_operation)) .route(
"/operations/import",
post(import_operation).layer(DefaultBodyLimit::max(256 * 1024)),
)
.route( .route(
"/operations/{operation_id}", "/operations/{operation_id}",
get(get_operation) get(get_operation)
@@ -83,6 +100,7 @@ pub fn build_app(state: AppState) -> Router {
) )
.route("/operations/{operation_id}/export", get(export_operation)) .route("/operations/{operation_id}/export", get(export_operation))
.route("/agents", get(list_agents).post(create_agent)) .route("/agents", get(list_agents).post(create_agent))
.route("/agents/tool-search/preview", post(preview_tool_search))
.route( .route(
"/agents/{agent_id}", "/agents/{agent_id}",
get(get_agent).patch(update_agent).delete(delete_agent), get(get_agent).patch(update_agent).delete(delete_agent),
@@ -125,12 +143,22 @@ pub fn build_app(state: AppState) -> Router {
.route("/secrets/{secret_id}/rotate", post(rotate_secret)) .route("/secrets/{secret_id}/rotate", post(rotate_secret))
.route("/export", get(export_workspace)) .route("/export", get(export_workspace))
.route("/logs", get(list_logs)) .route("/logs", get(list_logs))
.route("/logs/export.csv", get(export_logs_csv))
.route("/logs/{log_id}", get(get_log)) .route("/logs/{log_id}", get(get_log))
.route("/approvals", get(list_approvals)) .route("/approvals", get(list_approvals))
.route("/approvals/{approval_id}", get(get_approval)) .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", get(get_usage))
.route("/usage/operations/{operation_id}", get(get_operation_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() let workspace_root_router = Router::new()
.route("/capabilities", get(get_capabilities)) .route("/capabilities", get(get_capabilities))
@@ -156,6 +184,7 @@ pub fn build_app(state: AppState) -> Router {
let protected_auth_router = Router::new() let protected_auth_router = Router::new()
.route("/logout", post(logout)) .route("/logout", post(logout))
.route("/session", get(get_session)) .route("/session", get(get_session))
.route("/session/csrf", post(refresh_session_csrf))
.route("/profile", get(get_profile).patch(update_profile)) .route("/profile", get(get_profile).patch(update_profile))
.route("/password", post(change_password)) .route("/password", post(change_password))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
@@ -165,18 +194,32 @@ pub fn build_app(state: AppState) -> Router {
Router::new() Router::new()
.route("/health", get(crate::routes::health)) .route("/health", get(crate::routes::health))
.route("/ready", get(crate::routes::readiness))
.nest( .nest(
"/api/auth", "/api/auth",
Router::new() Router::new()
.route("/bootstrap/status", get(bootstrap_status))
.route("/bootstrap/complete", post(complete_bootstrap))
.route("/login", post(login)) .route("/login", post(login))
.merge(protected_auth_router), .merge(protected_auth_router),
) )
.nest("/api/admin", admin_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( .layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
apply_api_rate_limit, apply_api_rate_limit,
)) ))
.layer(middleware::from_fn(apply_request_context)) .layer(middleware::from_fn(apply_request_context))
.layer(middleware::from_fn(
crank_observability::record_http_request,
))
.with_state(state) .with_state(state)
} }
+131 -3
View File
@@ -1,22 +1,24 @@
use axum::{ use axum::{
extract::{OriginalUri, Request, State}, extract::{OriginalUri, Request, State},
http::{HeaderValue, Method, header},
middleware::Next, middleware::Next,
response::Response, response::Response,
}; };
use axum_extra::extract::cookie::{Cookie, CookieJar}; use axum_extra::extract::cookie::{Cookie, CookieJar};
use crank_community_auth::{ 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, create_session_cookie as build_session_cookie, hash_password as community_hash_password,
session_cookie as build_session_cookie_header, 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 crank_registry::WorkspaceMembershipRecord;
use serde::Serialize; use serde::Serialize;
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
pub use crank_community_auth::{ 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)] #[derive(Clone)]
@@ -53,6 +55,10 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, A
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}"))) .map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
} }
pub fn create_csrf_token_value() -> String {
build_csrf_token()
}
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> { pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours) 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 { if !has_access {
return Err(ApiError::forbidden("workspace access denied")); 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); request.extensions_mut().insert(session);
Ok(next.run(request).await) Ok(next.run(request).await)
} }
pub async fn require_csrf_for_browser_mutations(
State(state): State<AppState>,
jar: CookieJar,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
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<AppState>,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
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( async fn resolve_authenticated_session(
state: AppState, state: AppState,
jar: &CookieJar, jar: &CookieJar,
@@ -122,3 +198,55 @@ fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
Some(WorkspaceId::new(workspace_id)) 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)
}
+952
View File
@@ -0,0 +1,952 @@
#[path = "crank_migrate/admin_auth_command.rs"]
mod admin_auth_command;
#[path = "crank_migrate/db_connect.rs"]
mod db_connect;
use crank_config::{ConfigSource, parse_migrator};
use crank_registry::{
BackfillPolicy, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
MasterKeyRotationRecord, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
PostgresRegistry, RegistryError, SecretVersionRecord,
};
use crank_runtime::SecretCrypto;
use serde_json::json;
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 {
Ok(code) => code,
Err(error) => {
eprintln!(
"{}",
json!({
"status": "error",
"code": error.code,
"stage": error.stage,
"version": error.version,
"recovery": error.recovery,
})
);
ExitCode::FAILURE
}
}
}
#[derive(Clone, Copy)]
struct CliError {
code: &'static str,
stage: &'static str,
recovery: &'static str,
version: Option<i64>,
}
impl CliError {
const fn new(code: &'static str, stage: &'static str, recovery: &'static str) -> Self {
Self {
code,
stage,
recovery,
version: None,
}
}
fn from_migration(error: crank_registry::MigrationError) -> Self {
Self {
code: error.code(),
stage: error.stage(),
recovery: error.recovery(),
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<ExitCode, CliError> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
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 {
None | Some("preflight") => "preflight",
Some("plan") => "plan",
Some("apply") => "apply",
Some(_) => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_preflight",
));
}
};
if command == "plan" {
MigrationAuthority::validate_sequence().map_err(CliError::from_migration)?;
let sequence = MigrationAuthority::sequence()
.into_iter()
.map(|migration| {
let backfill = match migration.backfill {
BackfillPolicy::None => json!({ "kind": "none" }),
BackfillPolicy::Bounded {
max_batch_rows,
max_batch_ms,
resumable,
} => json!({
"kind": "bounded",
"max_batch_rows": max_batch_rows,
"max_batch_ms": max_batch_ms,
"resumable": resumable,
}),
};
json!({
"version": migration.version,
"name": migration.name,
"checksum": migration.checksum,
"source_digest": migration.source_digest,
"phase": migration.phase,
"compatibility": migration.compatibility,
"owner": migration.owner,
"transactional": migration.transactional,
"backfill": backfill,
"readable_schema_min": migration.readable_schema_min,
"readable_schema_max": migration.readable_schema_max,
"contract_evidence": migration.contract_evidence,
})
})
.collect::<Vec<_>>();
let plan = json!({ "schema_version": 1, "sequence": sequence });
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 {
return Err(CliError::new(
"contract_drift",
"plan.size",
"contact_operator",
));
}
let committed: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|_| CliError::new("contract_drift", "plan.parse", "contact_operator"))?;
if committed != plan {
return Err(CliError::new(
"contract_drift",
"plan.compare",
"contact_operator",
));
}
println!("{}", json!({ "status": "contract_current" }));
return Ok(ExitCode::SUCCESS);
}
if option.is_some() {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_preflight",
));
}
println!("{plan}");
return Ok(ExitCode::SUCCESS);
}
if option.is_some() {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_preflight",
));
}
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 pool = db_connect::connect(&config.database).await?;
if command == "apply" {
let result = MigrationAuthority::apply(&pool)
.await
.map_err(CliError::from_migration)?;
let (status, from, to) = match result {
MigrationApplyResult::Applied { from, to } => ("applied", from, to),
MigrationApplyResult::AlreadyCurrent { version } => {
("already_current", version, version)
}
};
println!(
"{}",
json!({ "status": status, "from_version": from, "to_version": to })
);
return Ok(ExitCode::SUCCESS);
}
match MigrationAuthority::preflight(&pool)
.await
.map_err(CliError::from_migration)?
{
MigrationPreflight::Current { version } => {
println!("{}", json!({ "status": "current", "version": version }));
Ok(ExitCode::SUCCESS)
}
MigrationPreflight::MigrationRequired { current, target } => {
println!(
"{}",
json!({
"status": "migration_required",
"current_version": current,
"target_version": target,
"recovery": "run_controlled_migration",
})
);
Ok(ExitCode::from(2))
}
}
}
async fn run_master_key(arguments: &[String]) -> Result<ExitCode, CliError> {
let Some(command) = arguments.first().map(String::as_str) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
};
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::<Vec<_>>(),
})
);
}
"preflight" => {
let preflight = master_key_preflight(&registry, &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(&registry, &["running"]).await? {
let (current_crypto, target_crypto) =
rotation_crypto_for_resume(&registry, &options, &rotation).await?;
(rotation, current_crypto, target_crypto)
} else {
let preflight = master_key_preflight(&registry, &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(
&registry,
&rotation,
&current_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(&registry, &["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",
));
}
let verified = verify_staged_targets(&registry, &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(&registry, &["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(&registry, &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",
));
}
}
Ok(ExitCode::SUCCESS)
}
#[derive(Default)]
struct MasterKeyOptions {
current_key_file: Option<String>,
target_key_file: Option<String>,
backup_ref: Option<String>,
rotation_id: Option<String>,
max_versions: Option<usize>,
}
impl MasterKeyOptions {
fn parse(arguments: &[String]) -> Result<Self, CliError> {
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::<usize>().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<String>,
current_crypto: SecretCrypto,
target_crypto: SecretCrypto,
}
async fn master_key_preflight(
registry: &PostgresRegistry,
options: &MasterKeyOptions,
) -> Result<MasterKeyPreflight, 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 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(&current_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, &current_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(&current_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<usize>,
) -> Result<i64, CliError> {
let mut processed = 0_i64;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = 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<i64, CliError> {
let mut verified = 0_i64;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = 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<usize, CliError> {
let mut count = 0_usize;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = 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<serde_json::Value, CliError> {
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<Option<MasterKeyRotationRecord>, 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<String, CliError> {
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(())
}
@@ -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<ExitCode, CliError> {
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(&registry, options).await?,
"bootstrap-complete" => complete_bootstrap_contract(&registry, options).await?,
"recover" => recover_admin_password(&registry, 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<String>,
display_name: Option<String>,
ttl_seconds: Option<i64>,
token_file: Option<PathBuf>,
password_file: Option<PathBuf>,
password_pepper_file: Option<PathBuf>,
master_key_file: Option<PathBuf>,
}
impl AdminAuthOptions {
fn parse(arguments: &[String]) -> Result<Self, CliError> {
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::<i64>().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<String, CliError> {
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())
}
@@ -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<PgPool, CliError> {
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<PostgresRegistry, CliError> {
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<PgConnectOptions, CliError> {
if let Some(url) = &config.url {
url.expose_secret()
.parse::<PgConnectOptions>()
.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()))
}
}
+228 -13
View File
@@ -1,13 +1,15 @@
use crank_core::{ use crank_core::{
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationAvailability,
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, OperationSecurityLevel, OperationStatus, OperationVersionState, PlatformApiKeyKind,
UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus, PlatformApiKeyScope, Protocol, SecretKind, Target, ToolSelectionPolicy, UsagePeriod,
WizardState, WorkspaceId, WorkspaceStatus,
}; };
use crank_mapping::MappingSet; use crank_mapping::MappingSet;
use crank_registry::{ use crank_registry::{
PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown, UsageOperationBreakdown, InvocationLogRecord, PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown,
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, UsageOperationBreakdown, UsageOutcomeGroup, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord,
}; };
use crank_schema::Schema; use crank_schema::Schema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -19,11 +21,23 @@ pub struct LoginPayload {
pub password: String, 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)] #[derive(Clone, Debug, Serialize)]
pub struct SessionResponse { pub struct SessionResponse {
pub user: crank_core::User, pub user: crank_core::User,
pub memberships: Vec<WorkspaceMembershipRecord>, pub memberships: Vec<WorkspaceMembershipRecord>,
pub current_workspace_id: Option<String>, pub current_workspace_id: Option<String>,
pub csrf_token: String,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -80,12 +94,19 @@ pub struct PublishPayload {
pub struct TestRunPayload { pub struct TestRunPayload {
pub version: u32, pub version: u32,
pub input: Value, pub input: Value,
#[serde(default)]
pub confirmation_token: Option<String>,
#[serde(default)]
pub locale: Option<String>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct TestRunResult { pub struct TestRunResult {
pub ok: bool, pub ok: bool,
pub mode: ExecutionMode, pub mode: ExecutionMode,
pub tested_version: u32,
pub request_id: String,
pub trace_id: String,
pub request_preview: Value, pub request_preview: Value,
pub response_preview: Value, pub response_preview: Value,
pub errors: Vec<Value>, pub errors: Vec<Value>,
@@ -144,7 +165,7 @@ pub struct AgentPayload {
#[serde(default)] #[serde(default)]
pub instructions: Value, pub instructions: Value,
#[serde(default)] #[serde(default)]
pub tool_selection_policy: Value, pub tool_selection_policy: ToolSelectionPolicy,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -165,6 +186,43 @@ pub struct AgentBindingPayload {
pub enabled: bool, pub enabled: bool,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct ToolSearchPreviewPayload {
pub query: String,
#[serde(default)]
pub group_ids: Vec<String>,
pub bindings: Vec<AgentBindingPayload>,
pub tool_selection_policy: ToolSelectionPolicy,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum AgentCatalogPayload {
Bindings(Vec<AgentBindingPayload>),
Config {
bindings: Vec<AgentBindingPayload>,
tool_selection_policy: ToolSelectionPolicy,
},
}
impl AgentCatalogPayload {
pub fn into_parts(self) -> (Vec<AgentBindingPayload>, Option<ToolSelectionPolicy>) {
match self {
Self::Bindings(bindings) => (bindings, None),
Self::Config {
bindings,
tool_selection_policy,
} => (bindings, Some(tool_selection_policy)),
}
}
}
impl From<Vec<AgentBindingPayload>> for AgentCatalogPayload {
fn from(bindings: Vec<AgentBindingPayload>) -> Self {
Self::Bindings(bindings)
}
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct CreatedAgentResponse { pub struct CreatedAgentResponse {
pub agent_id: String, pub agent_id: String,
@@ -191,11 +249,13 @@ pub struct AgentSummaryView {
pub status: AgentStatus, pub status: AgentStatus,
pub current_draft_version: u32, pub current_draft_version: u32,
pub latest_published_version: Option<u32>, pub latest_published_version: Option<u32>,
pub catalog_revision: i64,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
pub published_at: Option<String>, pub published_at: Option<String>,
pub operation_count: usize, pub operation_count: usize,
pub operation_ids: Vec<String>, pub operation_ids: Vec<String>,
pub tool_selection_policy: ToolSelectionPolicy,
pub key_count: usize, pub key_count: usize,
pub calls_today: u64, pub calls_today: u64,
pub mcp_endpoint: String, pub mcp_endpoint: String,
@@ -228,10 +288,100 @@ fn default_platform_api_key_kind() -> PlatformApiKeyKind {
pub struct CreatedPlatformApiKeyResponse { pub struct CreatedPlatformApiKeyResponse {
pub api_key: PlatformApiKeyRecord, pub api_key: PlatformApiKeyRecord,
pub secret: String, pub secret: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection: Option<EphemeralMcpConnection>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct WorkspaceExportResponse { pub struct EphemeralMcpClientConfig {
pub client: String,
pub config: Value,
}
#[derive(Clone, Debug, Serialize)]
pub struct EphemeralMcpConnection {
pub endpoint: String,
pub clients: Vec<EphemeralMcpClientConfig>,
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<Value>,
}
#[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<String>,
pub trace_id: Option<String>,
}
#[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<String>,
pub steps: Vec<OnboardingStepView>,
pub operation_id: Option<String>,
pub operation_version: Option<u32>,
pub agent_id: Option<String>,
pub catalog_revision: Option<i64>,
pub platform_api_key_id: Option<String>,
pub mcp_endpoint: Option<String>,
pub first_call: Option<OnboardingFirstCallEvidence>,
}
#[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)]
pub struct WorkspaceCatalogSnapshotResponse {
pub kind: String,
pub format_version: String,
pub restorable: bool,
pub included: Vec<String>,
pub excluded: Vec<String>,
pub workspace: WorkspaceRecord, pub workspace: WorkspaceRecord,
pub operations: Vec<OperationSummaryView>, pub operations: Vec<OperationSummaryView>,
pub agents: Vec<AgentSummaryView>, pub agents: Vec<AgentSummaryView>,
@@ -242,11 +392,16 @@ pub struct WorkspaceExportResponse {
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct LogsQuery { pub struct LogsQuery {
pub level: Option<InvocationLevel>, pub level: Option<InvocationLevel>,
pub status: Option<InvocationStatus>,
pub outcome_group: Option<UsageOutcomeGroup>,
pub search: Option<String>, pub search: Option<String>,
pub source: Option<InvocationSource>, pub source: Option<InvocationSource>,
pub operation_id: Option<String>, pub operation_id: Option<String>,
pub agent_id: Option<String>, pub agent_id: Option<String>,
pub period: Option<UsagePeriod>, pub period: Option<UsagePeriod>,
pub created_after: Option<String>,
pub created_before: Option<String>,
pub cursor: Option<String>,
pub limit: Option<u32>, pub limit: Option<u32>,
} }
@@ -256,10 +411,19 @@ pub struct ApprovalsQuery {
pub limit: Option<u32>, pub limit: Option<u32>,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct ApprovalDecisionPayload {
pub approve: String,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct UsageRequestQuery { pub struct UsageRequestQuery {
pub period: Option<UsagePeriod>, pub period: Option<UsagePeriod>,
pub source: Option<InvocationSource>, pub source: Option<InvocationSource>,
pub created_after: Option<String>,
pub created_before: Option<String>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -268,6 +432,13 @@ pub struct UsageOverviewResponse {
pub timeline: Vec<UsageTimelinePoint>, pub timeline: Vec<UsageTimelinePoint>,
pub operations: Vec<UsageOperationBreakdown>, pub operations: Vec<UsageOperationBreakdown>,
pub agents: Vec<UsageAgentBreakdown>, pub agents: Vec<UsageAgentBreakdown>,
pub outcomes: Vec<crank_registry::UsageOutcomeBreakdown>,
}
#[derive(Clone, Debug, Serialize)]
pub struct LogsListResponse {
pub items: Vec<InvocationLogRecord>,
pub next_cursor: Option<String>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -319,15 +490,51 @@ pub struct ExportQuery {
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct YamlOperationDocument { 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 format_version: String,
pub kind: String, pub kind: String,
pub operation: RegistryOperation, pub operation: RegistryOperation,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OpenApiImportPreviewPayload { pub enum OpenApiUploadLocale {
pub document: String, En,
Ru,
}
#[derive(Clone, Debug)]
pub struct OpenApiUpload {
pub bytes: Vec<u8>,
pub mime_type: String,
pub locale: OpenApiUploadLocale,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -353,14 +560,15 @@ pub struct OpenApiImportCreateResponse {
pub findings: Vec<crank_import::rest::ImportFinding>, pub findings: Vec<crank_import::rest::ImportFinding>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OpenApiImportCreatedOperation { pub struct OpenApiImportCreatedOperation {
pub operation_key: String,
pub operation_id: String, pub operation_id: String,
pub name: String, pub name: String,
pub version: u32, pub version: u32,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OpenApiImportSkippedOperation { pub struct OpenApiImportSkippedOperation {
pub operation_key: String, pub operation_key: String,
pub name: String, pub name: String,
@@ -430,6 +638,7 @@ pub struct OperationSummaryView {
pub status: OperationStatus, pub status: OperationStatus,
pub current_draft_version: u32, pub current_draft_version: u32,
pub latest_published_version: Option<u32>, pub latest_published_version: Option<u32>,
pub can_delete: bool,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
pub published_at: Option<String>, pub published_at: Option<String>,
@@ -447,6 +656,7 @@ pub struct OperationDetailView {
pub protocol: Protocol, pub protocol: Protocol,
pub security_level: OperationSecurityLevel, pub security_level: OperationSecurityLevel,
pub status: OperationStatus, pub status: OperationStatus,
pub availability: OperationAvailability,
pub current_draft_version: u32, pub current_draft_version: u32,
pub latest_published_version: Option<u32>, pub latest_published_version: Option<u32>,
pub created_at: String, pub created_at: String,
@@ -460,7 +670,7 @@ pub struct OperationDetailView {
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct VersionRef { pub struct VersionRef {
pub version: u32, pub version: u32,
pub status: OperationStatus, pub status: OperationVersionState,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -508,12 +718,17 @@ pub(crate) struct InvocationRecordRequest<'a> {
pub agent_id: Option<&'a AgentId>, pub agent_id: Option<&'a AgentId>,
pub operation: &'a RegistryOperation, pub operation: &'a RegistryOperation,
pub request_id: Option<&'a str>, pub request_id: Option<&'a str>,
pub trace_id: Option<&'a str>,
pub source: InvocationSource, pub source: InvocationSource,
pub level: InvocationLevel, pub level: InvocationLevel,
pub status: InvocationStatus, pub status: InvocationStatus,
pub message: String, pub message: String,
pub status_code: Option<u16>, pub status_code: Option<u16>,
pub error_kind: Option<String>, pub error_kind: Option<String>,
pub execution_stage: Option<crank_core::ExecutionStage>,
pub execution_error_code: Option<crank_core::ExecutionErrorCode>,
pub retryability: Option<crank_core::Retryability>,
pub outcome_certainty: Option<crank_core::OutcomeCertainty>,
pub duration_ms: u64, pub duration_ms: u64,
pub request_preview: Value, pub request_preview: Value,
pub response_preview: Value, pub response_preview: Value,
+422 -95
View File
@@ -11,6 +11,7 @@ use serde_json::{Value, json};
use thiserror::Error; use thiserror::Error;
use tracing::{error, warn}; use tracing::{error, warn};
use crate::dto::OpenApiUploadLocale;
use crate::storage::StorageError; use crate::storage::StorageError;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -31,6 +32,11 @@ pub enum ApiError {
context: Option<Value>, context: Option<Value>,
}, },
#[error("{message}")] #[error("{message}")]
Unprocessable {
message: String,
context: Option<Value>,
},
#[error("{message}")]
NotFound { NotFound {
message: String, message: String,
context: Option<Value>, context: Option<Value>,
@@ -41,6 +47,16 @@ pub enum ApiError {
context: Option<Value>, context: Option<Value>,
}, },
#[error("{message}")] #[error("{message}")]
PayloadTooLarge {
message: String,
context: Option<Value>,
},
#[error("{message}")]
PreconditionRequired {
message: String,
context: Option<Value>,
},
#[error("{message}")]
RateLimited { RateLimited {
message: String, message: String,
context: Option<Value>, context: Option<Value>,
@@ -74,13 +90,111 @@ impl ApiError {
} }
} }
pub fn internal(message: impl Into<String>) -> Self { pub fn internal(_message: impl Into<String>) -> Self {
Self::Internal { Self::Internal {
message: message.into(), message: "internal server error".to_owned(),
context: None, context: None,
} }
} }
pub(crate) fn openapi_upload(locale: OpenApiUploadLocale, code: &'static str) -> Self {
let russian = locale == OpenApiUploadLocale::Ru;
let message = match (russian, code) {
(_, "file_too_large") => {
if russian {
"файл OpenAPI превышает лимит 256 КиБ"
} else {
"OpenAPI file exceeds the 256 KiB limit"
}
}
(_, "empty_file") => {
if russian {
"файл OpenAPI не должен быть пустым"
} else {
"OpenAPI file must not be empty"
}
}
(_, "invalid_utf8") => {
if russian {
"файл OpenAPI должен быть в UTF-8"
} else {
"OpenAPI file must be UTF-8"
}
}
(_, "invalid_media_type") => {
if russian {
"тип файла OpenAPI не поддерживается"
} else {
"OpenAPI file type is not supported"
}
}
(_, "invalid_document") => {
if russian {
"некорректный или неподдерживаемый документ OpenAPI"
} else {
"OpenAPI document is invalid or unsupported"
}
}
(_, "no_methods") => {
if russian {
"документ OpenAPI не содержит поддерживаемых методов"
} else {
"OpenAPI document contains no supported methods"
}
}
(_, "source_integrity") => {
if russian {
"проверка целостности источника OpenAPI не пройдена"
} else {
"OpenAPI source integrity verification failed"
}
}
(_, "source_unavailable") => {
if russian {
"источник OpenAPI недоступен"
} else {
"OpenAPI source is unavailable"
}
}
(_, "parser_unavailable") | (_, "storage_unavailable") => {
if russian {
"обработка OpenAPI временно недоступна"
} else {
"OpenAPI processing is temporarily unavailable"
}
}
_ => {
if russian {
"некорректная multipart-загрузка OpenAPI"
} else {
"invalid OpenAPI multipart upload"
}
}
};
let context = json!({ "error_code": format!("openapi_upload.{code}") });
if code == "file_too_large" {
Self::PayloadTooLarge {
message: message.to_owned(),
context: Some(context),
}
} else if matches!(code, "storage_unavailable" | "parser_unavailable") {
Self::Internal {
message: message.to_owned(),
context: Some(context),
}
} else if matches!(code, "source_integrity" | "source_unavailable") {
Self::Unprocessable {
message: message.to_owned(),
context: Some(context),
}
} else {
Self::Validation {
message: message.to_owned(),
context: Some(context),
}
}
}
pub(crate) fn rate_limited_with_context(message: impl Into<String>, context: Value) -> Self { pub(crate) fn rate_limited_with_context(message: impl Into<String>, context: Value) -> Self {
Self::RateLimited { Self::RateLimited {
message: message.into(), message: message.into(),
@@ -95,6 +209,13 @@ impl ApiError {
} }
} }
pub(crate) fn unprocessable_with_context(message: impl Into<String>, context: Value) -> Self {
Self::Unprocessable {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self { pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
Self::NotFound { Self::NotFound {
message: message.into(), message: message.into(),
@@ -109,25 +230,58 @@ impl ApiError {
} }
} }
pub(crate) fn payload_too_large_with_context(
message: impl Into<String>,
context: Value,
) -> Self {
Self::PayloadTooLarge {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn precondition_required_with_context(
message: impl Into<String>,
context: Value,
) -> Self {
Self::PreconditionRequired {
message: message.into(),
context: Some(context),
}
}
fn status_code(&self) -> StatusCode { fn status_code(&self) -> StatusCode {
match self { match self {
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED, Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
Self::Forbidden { .. } => StatusCode::FORBIDDEN, Self::Forbidden { .. } => StatusCode::FORBIDDEN,
Self::Validation { .. } => StatusCode::BAD_REQUEST, Self::Validation { .. } => StatusCode::BAD_REQUEST,
Self::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY,
Self::NotFound { .. } => StatusCode::NOT_FOUND, Self::NotFound { .. } => StatusCode::NOT_FOUND,
Self::Conflict { .. } => StatusCode::CONFLICT, Self::Conflict { .. } => StatusCode::CONFLICT,
Self::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
Self::PreconditionRequired { .. } => StatusCode::PRECONDITION_REQUIRED,
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS, Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, 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 { match self {
Self::Unauthorized { .. } => "unauthorized", Self::Unauthorized { .. } => "unauthorized",
Self::Forbidden { .. } => "forbidden", Self::Forbidden { .. } => "forbidden",
Self::Validation { .. } => "validation_error", Self::Validation { .. } => "validation_error",
Self::Unprocessable { .. } => "unprocessable_entity",
Self::NotFound { .. } => "not_found", Self::NotFound { .. } => "not_found",
Self::Conflict { .. } => "conflict", Self::Conflict { .. } => "conflict",
Self::PayloadTooLarge { .. } => "payload_too_large",
Self::PreconditionRequired { .. } => "precondition_required",
Self::RateLimited { .. } => "rate_limited", Self::RateLimited { .. } => "rate_limited",
Self::Internal { .. } => "internal_error", Self::Internal { .. } => "internal_error",
} }
@@ -137,16 +291,27 @@ impl ApiError {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match &self { match &self {
Self::Internal { message, .. } => { Self::Internal { .. } => {
error!(error_code = self.code(), error_message = %message) error!(
name: "admin.response.internal_error",
error_code = self.code(),
"internal API error response"
)
} }
Self::Unauthorized { message, .. } Self::Unauthorized { .. }
| Self::Forbidden { message, .. } | Self::Forbidden { .. }
| Self::Validation { message, .. } | Self::Validation { .. }
| Self::NotFound { message, .. } | Self::Unprocessable { .. }
| Self::Conflict { message, .. } | Self::NotFound { .. }
| Self::RateLimited { message, .. } => { | Self::Conflict { .. }
warn!(error_code = self.code(), error_message = %message) | Self::PayloadTooLarge { .. }
| Self::PreconditionRequired { .. }
| Self::RateLimited { .. } => {
warn!(
name: "admin.response.rejected",
error_code = self.code(),
"API request rejected"
)
} }
} }
@@ -157,6 +322,13 @@ impl IntoResponse for ApiError {
if let Some(context) = self.context() { if let Some(context) = self.context() {
error["context"] = context; error["context"] = context;
} }
let (request_id, trace_id) = crank_observability::current_request_correlation();
if let Some(request_id) = request_id {
error["request_id"] = Value::String(request_id);
}
if let Some(trace_id) = trace_id {
error["trace_id"] = Value::String(trace_id);
}
let body = Json(json!({ let body = Json(json!({
"error": error "error": error
@@ -167,17 +339,24 @@ impl IntoResponse for ApiError {
} }
impl ApiError { impl ApiError {
fn context(&self) -> Option<Value> { fn context_ref(&self) -> Option<&Value> {
match self { match self {
Self::Unauthorized { context, .. } Self::Unauthorized { context, .. }
| Self::Forbidden { context, .. } | Self::Forbidden { context, .. }
| Self::Validation { context, .. } | Self::Validation { context, .. }
| Self::Unprocessable { context, .. }
| Self::NotFound { context, .. } | Self::NotFound { context, .. }
| Self::Conflict { context, .. } | Self::Conflict { context, .. }
| Self::PayloadTooLarge { context, .. }
| Self::PreconditionRequired { context, .. }
| Self::RateLimited { context, .. } | Self::RateLimited { context, .. }
| Self::Internal { context, .. } => context.clone(), | Self::Internal { context, .. } => context.as_ref(),
} }
} }
fn context(&self) -> Option<Value> {
self.context_ref().cloned()
}
} }
impl From<RegistryError> for ApiError { impl From<RegistryError> for ApiError {
@@ -213,10 +392,41 @@ impl From<RegistryError> for ApiError {
format!("platform api key {key_id} was not found"), format!("platform api key {key_id} was not found"),
json!({ "key_id": key_id }), 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( RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
format!("secret {secret_id} was not found"), format!("secret {secret_id} was not found"),
json!({ "secret_id": secret_id }), 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( RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
format!("invocation log {log_id} was not found"), format!("invocation log {log_id} was not found"),
json!({ "log_id": log_id }), json!({ "log_id": log_id }),
@@ -253,6 +463,57 @@ impl From<RegistryError> for ApiError {
json!({ "operation_id": operation_id }), 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( RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context(
format!("auth profile {auth_profile_id} was not found"), format!("auth profile {auth_profile_id} was not found"),
json!({ "auth_profile_id": auth_profile_id }), json!({ "auth_profile_id": auth_profile_id }),
@@ -261,6 +522,17 @@ impl From<RegistryError> for ApiError {
format!("operation {operation_id} already exists"), format!("operation {operation_id} already exists"),
json!({ "operation_id": operation_id }), 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( RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context(
format!("workspace with slug {slug} already exists"), format!("workspace with slug {slug} already exists"),
json!({ "slug": slug }), json!({ "slug": slug }),
@@ -271,6 +543,8 @@ impl From<RegistryError> for ApiError {
json!({ json!({
"workspace_id": workspace_id, "workspace_id": workspace_id,
"name": name, "name": name,
"error_code": "secret_name_conflict",
"recovery": "choose_different_name"
}), }),
) )
} }
@@ -282,12 +556,29 @@ impl From<RegistryError> for ApiError {
json!({ json!({
"secret_id": secret_id, "secret_id": secret_id,
"auth_profile_id": auth_profile_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( RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
format!("user with email {email} already exists"), format!("user with email {email} already exists"),
json!({ "email": email }), 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 { RegistryError::InvalidInitialVersion {
operation_id, operation_id,
version, version,
@@ -322,6 +613,39 @@ impl From<RegistryError> for ApiError {
"actual": actual, "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 { RegistryError::ImmutableOperationFieldChanged {
operation_id, operation_id,
field, field,
@@ -332,6 +656,14 @@ impl From<RegistryError> for ApiError {
"field": field, "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( RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context(
format!("unsupported enum representation for field {field}"), format!("unsupported enum representation for field {field}"),
json!({ "field": field }), json!({ "field": field }),
@@ -351,8 +683,56 @@ impl From<RegistryError> for ApiError {
format!("import job {job_id} was not found"), format!("import job {job_id} was not found"),
json!({ "job_id": job_id }), json!({ "job_id": job_id }),
), ),
RegistryError::Storage(_) | RegistryError::Serialization(_) => { RegistryError::ImportJobAlreadyApplied { job_id } => Self::conflict_with_context(
Self::internal(value.to_string()) format!("import job {job_id} was already applied with different parameters"),
json!({ "job_id": job_id }),
),
RegistryError::SourceNotFound { .. } => Self::not_found_with_context(
"artifact source was not found",
json!({
"error_code": "artifact_source_not_found"
}),
),
RegistryError::SourceConflict { .. } => Self::conflict_with_context(
"artifact source metadata or lifecycle conflicts with the request",
json!({
"error_code": "artifact_source_conflict",
"recovery": "reload"
}),
),
RegistryError::SourceUnavailable => Self::unprocessable_with_context(
"artifact source is unavailable",
json!({ "error_code": "artifact_source_unavailable" }),
),
RegistryError::SourceIntegrity => Self::unprocessable_with_context(
"artifact source failed integrity verification",
json!({ "error_code": "artifact_source_integrity" }),
),
RegistryError::ArtifactClaimInProgress => Self::conflict_with_context(
"artifact reconciliation is in progress",
json!({
"error_code": "artifact_claim_in_progress",
"recovery": "retry"
}),
),
RegistryError::InvalidArtifactSource { field } => Self::validation_with_context(
"artifact source metadata is invalid",
json!({
"field": field,
"error_code": "artifact_source_invalid"
}),
),
RegistryError::Storage(_) => Self::internal("registry operation failed"),
RegistryError::Migration(_)
| RegistryError::Serialization(_)
| RegistryError::MasterKeyIdentityMismatch { .. }
| RegistryError::InvalidMasterKeyIdentity
| RegistryError::MasterKeyRotationNotFound { .. }
| RegistryError::MasterKeyRotationConflict
| RegistryError::MasterKeyRotationVerificationFailed
| RegistryError::InvalidCorrelationIdentity { .. }
| RegistryError::InvalidExecutionRecord { .. } => {
Self::internal("registry operation failed")
} }
} }
} }
@@ -385,87 +765,34 @@ impl From<StorageError> for ApiError {
} }
pub fn runtime_test_failure(error: &RuntimeError) -> Value { 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!({ let mut payload = json!({
"code": runtime_test_failure_code(error), "code": failure.error_code().as_str(),
"message": error.to_string() "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(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(),
}); });
if let Some(context) = runtime_error_context(error) {
payload["context"] = context;
} }
payload payload
} }
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::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<Value> {
match error {
RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({
"field": field,
"reason": reason,
})),
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 } => Some(json!({
"operation_id": operation_id,
})),
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
"secret_id": secret_id,
"reason": reason,
})),
RuntimeError::SecretCrypto { operation, details } => Some(json!({
"operation": operation,
"details": details,
})),
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,
}
}
+2
View File
@@ -3,7 +3,9 @@ pub mod auth;
pub mod dto; pub mod dto;
pub mod error; pub mod error;
pub mod import_guidance; pub mod import_guidance;
pub mod pool_metrics;
pub mod rate_limit; pub mod rate_limit;
pub mod reconciliation;
pub mod request_context; pub mod request_context;
pub mod routes; pub mod routes;
pub mod service; pub mod service;
+439 -78
View File
@@ -1,62 +1,216 @@
use std::{env, net::SocketAddr, path::PathBuf}; use std::{io, net::SocketAddr, process::ExitCode, time::Duration};
use admin_api::{ use admin_api::{
app::build_app, app::build_app,
auth::{AuthSettings, BootstrapAdminConfig}, auth::{AuthSettings, BootstrapAdminConfig},
pool_metrics::spawn_postgres_pool_metrics,
reconciliation::{open_reconciliation_store, spawn_artifact_reconciliation},
service::AdminServiceBuilder, service::AdminServiceBuilder,
state::AppState, state::AppState,
}; };
use crank_community_auth::PasswordIdentityProvider; use crank_community_auth::PasswordIdentityProvider;
use crank_registry::{PostgresPoolConfig, PostgresRegistry}; use crank_config::{
AdminProcessConfig, CacheBackend as ConfigCacheBackend, ConfigSource, DatabaseSettings,
DiagnosticCode, ObservabilitySettings, ProcessKind, parse_process,
};
use crank_core::CacheBackend;
use crank_observability::{
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
OtlpTraceConfig, RedactionLimits, SentryConfig, ServiceIdentity, capture_critical_error,
};
use crank_registry::{
MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresPoolConfig, PostgresRegistry,
};
use crank_runtime::{ use crank_runtime::{
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores, RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
RuntimeLimits, SecretCrypto, community_default, RuntimeLimits, SecretCrypto,
}; };
use sqlx::postgres::PgConnectOptions; use sqlx::postgres::PgConnectOptions;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::info; use tracing::{info, warn};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> ExitCode {
tracing_subscriber::fmt() match main_result().await {
.with_env_filter( Ok(()) => ExitCode::SUCCESS,
env::var("CRANK_LOG_LEVEL").unwrap_or_else(|_| "admin_api=info,tower_http=info".into()), Err(error) => {
) eprintln!("{}", safe_startup_diagnostic(error.as_ref()));
.init(); ExitCode::FAILURE
}
}
}
let storage_root = PathBuf::from( fn safe_startup_diagnostic(error: &(dyn std::error::Error + 'static)) -> String {
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()), let mut current = Some(error);
while let Some(cause) = current {
if let Some(config) = cause.downcast_ref::<crank_config::ConfigError>() {
return config.to_json();
}
if let Some(migration) = cause.downcast_ref::<crank_registry::MigrationError>() {
return serde_json::json!({
"status": "error",
"code": migration.code(),
"stage": migration.stage(),
"version": migration.version(),
"recovery": migration.recovery(),
})
.to_string();
}
if let Some(crank_registry::RegistryError::Migration(migration)) =
cause.downcast_ref::<crank_registry::RegistryError>()
{
return serde_json::json!({
"status": "error",
"code": migration.code(),
"stage": migration.stage(),
"version": migration.version(),
"recovery": migration.recovery(),
})
.to_string();
}
if let Some(crank_registry::RegistryError::MasterKeyIdentityMismatch { epoch }) =
cause.downcast_ref::<crank_registry::RegistryError>()
{
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::<crank_registry::RegistryError>()
.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!({
"status": "error",
"code": "startup_failed",
"stage": "startup",
"version": null,
"recovery": "contact_operator",
})
.to_string()
}
async fn main_result() -> Result<(), Box<dyn std::error::Error>> {
let effective = parse_process(ProcessKind::AdminApi, ConfigSource::from_os()?)?;
let config = effective
.admin()
.cloned()
.ok_or_else(|| io::Error::other("admin configuration projection is unavailable"))?;
preflight_config(&config)?;
let observability = init_observability(&config.observability)?;
for deprecation in effective.deprecations() {
warn!(
name: "config.deprecated",
field = deprecation.field,
source_class = deprecation.source_class,
replacement = deprecation.replacement,
removal_window = deprecation.removal_window,
"deprecated configuration accepted"
); );
let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into()); }
let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()); let mut startup_completed = false;
let socket_addr: SocketAddr = bind_addr.parse()?; let result = run(config, &observability, &mut startup_completed).await;
let pool_config = PostgresPoolConfig::from_env()?; if result.is_err() {
capture_critical_error(if startup_completed {
CriticalErrorCategory::Internal
} else {
CriticalErrorCategory::Startup
});
}
result
}
async fn run(
config: AdminProcessConfig,
observability: &ObservabilityLifecycle,
startup_completed: &mut bool,
) -> Result<(), Box<dyn std::error::Error>> {
let metrics_config = MetricsConfig::new(
config.observability.metrics.enabled,
config.observability.metrics.bind_addr,
config
.observability
.metrics
.bearer_token
.as_ref()
.map(|token| token.expose_secret().to_owned()),
)?;
let metrics_enabled = metrics_config.enabled();
let pool_config = postgres_pool_config(&config.database)?;
let registry = PostgresRegistry::connect_with_options_and_pool_config( let registry = PostgresRegistry::connect_with_options_and_pool_config(
database_options_from_env()?, database_options(&config.database)?,
pool_config, pool_config,
) )
.await?; .await?;
let metrics_server = if metrics_enabled {
Some(observability.metrics_surface(metrics_config).bind().await?)
} else {
None
};
if metrics_enabled {
spawn_postgres_pool_metrics(registry.pool().clone());
}
let base_url = config
.runtime
.base_url
.clone()
.unwrap_or_else(|| "http://localhost:3000".to_owned());
let auth_settings = AuthSettings { let auth_settings = AuthSettings {
session_secret: env::var("CRANK_SESSION_SECRET")?, session_secret: config.session_secret.expose_secret().to_owned(),
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?, password_pepper: config.password_pepper.expose_secret().to_owned(),
session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS") session_ttl_hours: config.session_ttl_hours,
.ok()
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(24),
cookie_secure: base_url.starts_with("https://"), cookie_secure: base_url.starts_with("https://"),
bootstrap_admin: BootstrapAdminConfig { bootstrap_admin: BootstrapAdminConfig {
email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?, email: config.bootstrap_email.clone(),
password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?, password: config
display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME") .bootstrap_password
.unwrap_or_else(|_| "Crank Owner".into()), .as_ref()
.map(|password| password.expose_secret().to_owned())
.unwrap_or_default(),
display_name: config.bootstrap_display_name.clone(),
}, },
}; };
let runtime_limits = RuntimeLimits::from_env()?; let runtime_limits = RuntimeLimits::try_new(
let cache_config = RuntimeCacheConfig::from_env()?; config.runtime.max_concurrent_unary,
config.runtime.max_concurrent_sessions,
)?;
let cache_config = runtime_cache_config(&config)?;
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?; let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
let api_rate_limit = admin_api_rate_limit_config_from_env()?; let api_rate_limit = RequestRateLimitConfig::new(
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; config.rate_limit.requests_per_second,
let runtime = community_default() config.rate_limit.burst,
)?;
let secret_crypto =
verified_startup_secret_crypto(&registry, config.runtime.master_key.expose_secret())
.await?;
let artifact_store = open_reconciliation_store(config.storage_root.clone()).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())
.with_limits(runtime_limits) .with_limits(runtime_limits)
.with_response_cache(cache_stores.response.clone()) .with_response_cache(cache_stores.response.clone())
.with_coordination_store(cache_stores.coordination.clone()) .with_coordination_store(cache_stores.coordination.clone())
@@ -64,18 +218,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let identity_provider = let identity_provider =
PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone()); PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone());
let service = AdminServiceBuilder::new( let service = AdminServiceBuilder::new(
registry, registry.clone(),
storage_root, config.storage_root.clone(),
auth_settings, auth_settings,
secret_crypto, secret_crypto,
runtime, runtime,
) )
.with_artifact_store(artifact_store.clone())
.with_public_base_url(base_url)
.with_outbound_http_policy(outbound_http_policy)
.with_external_reference_import(&config.external_references)?
.with_identity_provider(std::sync::Arc::new(identity_provider)) .with_identity_provider(std::sync::Arc::new(identity_provider))
.build(); .build();
service.bootstrap_admin_user().await?; if config.demo_seed {
if env_flag("CRANK_DEMO_SEED") {
service.seed_demo_assets().await?; service.seed_demo_assets().await?;
} }
spawn_artifact_reconciliation(registry, artifact_store).await;
spawn_invocation_log_cleanup(service.clone(), config.invocation_log_retention_days);
let state = AppState { let state = AppState {
service, service,
api_rate_limiter: if cache_config.backend.is_external() { api_rate_limiter: if cache_config.backend.is_external() {
@@ -83,11 +242,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} else { } else {
RequestRateLimiter::new(api_rate_limit) RequestRateLimiter::new(api_rate_limit)
}, },
trusted_proxy_ips: config.trusted_proxy_ips.clone(),
}; };
let app = build_app(state); let app = build_app(state);
let listener = TcpListener::bind(socket_addr).await?; let listener = TcpListener::bind(config.bind_addr).await?;
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
info!( info!(
name: "admin.postgres_pool.configured",
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary, runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
admin_rate_limit_rps = api_rate_limit.requests_per_second, admin_rate_limit_rps = api_rate_limit.requests_per_second,
admin_rate_limit_burst = api_rate_limit.burst, admin_rate_limit_burst = api_rate_limit.burst,
@@ -97,58 +259,257 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
acquire_timeout_ms = pool_config.acquire_timeout_ms, acquire_timeout_ms = pool_config.acquire_timeout_ms,
idle_timeout_ms = pool_config.idle_timeout_ms, idle_timeout_ms = pool_config.idle_timeout_ms,
max_lifetime_ms = pool_config.max_lifetime_ms, max_lifetime_ms = pool_config.max_lifetime_ms,
invocation_log_retention_days = config.invocation_log_retention_days,
"postgres pool configured" "postgres pool configured"
); );
info!("admin-api listening on {}", socket_addr); info!(name: "admin.server.listening", bind_address = %config.bind_addr, "admin-api listening");
*startup_completed = true;
axum::serve(listener, app).await?;
if let Some(metrics_server) = metrics_server {
tokio::select! {
result = axum::serve(listener, make_service) => result?,
result = metrics_server.serve() => result?,
}
} else {
axum::serve(listener, make_service).await?;
}
Ok(()) Ok(())
} }
fn env_flag(name: &str) -> bool { fn init_observability(
matches!( config: &ObservabilitySettings,
env::var(name) ) -> Result<ObservabilityLifecycle, Box<dyn std::error::Error>> {
.ok() let identity = ServiceIdentity::try_new(
.as_deref() "admin-api",
.map(str::to_ascii_lowercase) env!("CARGO_PKG_VERSION"),
.as_deref(), config.environment.clone(),
Some("1" | "true" | "yes" | "on") )?;
let base = ObservabilityConfig::try_new(
identity,
config.log_filter.clone(),
RedactionLimits::default(),
)?;
let sentry = SentryConfig::parse(
config
.sentry_dsn
.as_ref()
.map(|value| value.expose_secret()),
)?;
let otlp = otlp_config(config)?;
Ok(ObservabilityLifecycle::init_with_exporters(
base, sentry, otlp,
)?)
}
fn preflight_config(config: &AdminProcessConfig) -> Result<(), crank_config::ConfigError> {
let invalid = |field| crank_config::ConfigError::single(DiagnosticCode::InvalidType, field);
database_options(&config.database).map_err(|_| invalid("database.source"))?;
postgres_pool_config(&config.database).map_err(|_| invalid("database.pool"))?;
MetricsConfig::new(
config.observability.metrics.enabled,
config.observability.metrics.bind_addr,
config
.observability
.metrics
.bearer_token
.as_ref()
.map(|v| v.expose_secret().to_owned()),
)
.map_err(|_| invalid("observability.metrics"))?;
RuntimeLimits::try_new(
config.runtime.max_concurrent_unary,
config.runtime.max_concurrent_sessions,
)
.map_err(|_| invalid("runtime.limits"))?;
runtime_cache_config(config).map_err(|_| invalid("cache"))?;
RequestRateLimitConfig::new(
config.rate_limit.requests_per_second,
config.rate_limit.burst,
)
.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_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"))?;
let identity = ServiceIdentity::try_new(
"admin-api",
env!("CARGO_PKG_VERSION"),
config.observability.environment.clone(),
)
.map_err(|_| invalid("observability.environment"))?;
ObservabilityConfig::try_new(
identity,
config.observability.log_filter.clone(),
RedactionLimits::default(),
)
.map_err(|_| invalid("observability.log_filter"))?;
SentryConfig::parse(
config
.observability
.sentry_dsn
.as_ref()
.map(|v| v.expose_secret()),
)
.map_err(|_| invalid("observability.sentry_dsn"))?;
otlp_config(&config.observability).map_err(|_| invalid("observability.otlp"))?;
Ok(())
}
async fn verified_startup_secret_crypto(
registry: &PostgresRegistry,
master_key: &str,
) -> Result<SecretCrypto, Box<dyn std::error::Error>> {
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<String> = None;
let mut after_version: Option<u32> = 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<OtlpTraceConfig, crank_observability::OtlpTraceConfigError> {
let values = &config.otlp;
OtlpTraceConfig::from_values(
values.endpoint.clone(),
values.traces_endpoint.clone(),
values.protocol.clone(),
values.traces_protocol.clone(),
values.timeout.clone(),
values.traces_timeout.clone(),
values
.headers
.as_ref()
.map(|value| value.expose_secret().to_owned()),
values
.traces_headers
.as_ref()
.map(|value| value.expose_secret().to_owned()),
values.max_queue_size,
values.max_export_batch_size,
values.schedule_delay.clone(),
values.export_timeout.clone(),
) )
} }
fn admin_api_rate_limit_config_from_env() fn postgres_pool_config(
-> Result<RequestRateLimitConfig, Box<dyn std::error::Error>> { config: &DatabaseSettings,
let requests_per_second = env::var("CRANK_ADMIN_RATE_LIMIT_RPS") ) -> Result<PostgresPoolConfig, crank_registry::PostgresPoolConfigError> {
.ok() PostgresPoolConfig::try_new(
.and_then(|value| value.parse::<u32>().ok()) config.pool.max_connections,
.unwrap_or(30); config.pool.min_connections,
let burst = env::var("CRANK_ADMIN_RATE_LIMIT_BURST") config.pool.acquire_timeout_ms,
.ok() config.pool.idle_timeout_ms,
.and_then(|value| value.parse::<u32>().ok()) config.pool.max_lifetime_ms,
.unwrap_or(60); )
Ok(RequestRateLimitConfig::new(requests_per_second, burst)?)
} }
fn database_options_from_env() -> Result<PgConnectOptions, Box<dyn std::error::Error>> { fn database_options(
if let Ok(database_url) = env::var("CRANK_DATABASE_URL") { config: &DatabaseSettings,
return Ok(database_url.parse::<PgConnectOptions>()?); ) -> Result<PgConnectOptions, Box<dyn std::error::Error>> {
if let Some(url) = &config.url {
return url
.expose_secret()
.parse::<PgConnectOptions>()
.map_err(|_| io::Error::other("database URL is invalid").into());
} }
let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into());
let port = env::var("POSTGRES_PORT")
.ok()
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(5432);
let database = env::var("POSTGRES_DB").unwrap_or_else(|_| "crank".into());
let username = env::var("POSTGRES_USER").unwrap_or_else(|_| "crank".into());
let password = env::var("POSTGRES_PASSWORD").unwrap_or_else(|_| "crank".into());
Ok(PgConnectOptions::new() Ok(PgConnectOptions::new()
.host(&host) .host(&config.host)
.port(port) .port(config.port)
.database(&database) .database(&config.database)
.username(&username) .username(&config.username)
.password(&password)) .password(config.password.expose_secret()))
}
fn runtime_cache_config(
config: &AdminProcessConfig,
) -> Result<RuntimeCacheConfig, crank_runtime::RuntimeCacheConfigError> {
RuntimeCacheConfig::try_new(
match config.runtime.cache.backend {
ConfigCacheBackend::Memory => CacheBackend::Memory,
ConfigCacheBackend::Valkey => CacheBackend::Valkey,
ConfigCacheBackend::Redis => CacheBackend::Redis,
},
config
.runtime
.cache
.url
.as_ref()
.map(|value| value.expose_secret().to_owned()),
)
}
fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, retention_days: i64) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60 * 60));
loop {
interval.tick().await;
let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days);
match service.cleanup_invocation_logs_before(cutoff).await {
Ok(outcome) if outcome.deleted_records > 0 => info!(
name: "admin.invocation_log_cleanup.completed",
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(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",
"failed to remove expired invocation logs"
),
}
}
});
} }
+13
View File
@@ -0,0 +1,13 @@
use std::time::Duration;
use sqlx::PgPool;
pub fn spawn_postgres_pool_metrics(pool: PgPool) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(5));
loop {
interval.tick().await;
crank_observability::record_db_pool_connections(pool.size(), pool.num_idle());
}
})
}
+158 -43
View File
@@ -1,25 +1,46 @@
use std::net::{IpAddr, SocketAddr};
use axum::{ use axum::{
extract::{Request, State}, extract::{ConnectInfo, Request, State},
http::header::{COOKIE, HeaderMap}, http::HeaderMap,
middleware::Next, middleware::Next,
response::Response, response::Response,
}; };
use crank_runtime::RateLimitRejection; use crank_runtime::{RateLimitCheckError, RateLimitRejection};
use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Clone, Debug)]
pub struct ClientIdentityBucket(pub String);
pub async fn apply_api_rate_limit( pub async fn apply_api_rate_limit(
State(state): State<AppState>, State(state): State<AppState>,
request: Request, mut request: Request,
next: Next, next: Next,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let key = rate_limit_key(request.headers(), request.uri().path()); let peer_ip = request
if let Err(rejection) = state.api_rate_limiter.check(&key).await { .extensions()
return Err(ApiError::rate_limited_with_context( .get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(address)| address.ip());
let key = client_rate_limit_key(
request.headers(),
request.uri().path(),
peer_ip,
&state.trusted_proxy_ips,
);
if let Err(error) = state.api_rate_limiter.check(&key).await {
return match error {
RateLimitCheckError::Rejected(rejection) => Err(ApiError::rate_limited_with_context(
"request rate limit exceeded", "request rate limit exceeded",
rejection_context(rejection), rejection_context(rejection),
)); )),
RateLimitCheckError::StoreUnavailable => {
Err(ApiError::internal("rate limit service unavailable"))
} }
};
}
request.extensions_mut().insert(ClientIdentityBucket(key));
Ok(next.run(request).await) Ok(next.run(request).await)
} }
@@ -30,56 +51,67 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
}) })
} }
fn rate_limit_key(headers: &HeaderMap, path: &str) -> String { pub fn client_rate_limit_key(
if let Some(session_id) = session_id_from_headers(headers) { headers: &HeaderMap,
return format!("session:{session_id}"); path: &str,
peer_ip: Option<IpAddr>,
trusted_proxy_ips: &[IpAddr],
) -> String {
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}");
} }
if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") { if let Some(peer_ip) = peer_ip {
let ip = forwarded_for return format!("ip:{peer_ip}");
.split(',')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown");
return format!("ip:{ip}");
}
if let Some(real_ip) = header_value(headers, "x-real-ip") {
return format!("ip:{real_ip}");
} }
format!("anonymous:{path}") format!("anonymous:{path}")
} }
fn session_id_from_headers(headers: &HeaderMap) -> Option<String> { /// Resolves the client IP from proxy headers after the immediate peer was
let cookies = headers.get(COOKIE)?.to_str().ok()?; /// matched against the trusted-proxy allowlist.
for part in cookies.split(';') { ///
let (name, value) = part.trim().split_once('=')?; /// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
if name != SESSION_COOKIE_NAME { /// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
continue; /// peer, so the last entry is the trustworthy hop; taking the first entry (as
} /// naive implementations do) would let a client spoof its address by sending a
let (session_id, _) = value.split_once('.')?; /// pre-populated header.
if !session_id.is_empty() { fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
return Some(session_id.to_owned()); if let Some(real_ip) = header_value(headers, "x-real-ip").and_then(parse_ip) {
} return Some(real_ip);
} }
None header_value(headers, "x-forwarded-for")?
.split(',')
.map(str::trim)
.rfind(|value| !value.is_empty())
.and_then(parse_ip)
} }
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> { fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
headers.get(name)?.to_str().ok().map(str::trim) headers.get(name)?.to_str().ok().map(str::trim)
} }
fn parse_ip(value: &str) -> Option<IpAddr> {
value.parse().ok()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::net::{IpAddr, Ipv4Addr};
use axum::http::{HeaderMap, HeaderValue, header::COOKIE}; use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
use super::rate_limit_key; use super::client_rate_limit_key;
fn peer() -> Option<IpAddr> {
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
}
#[test] #[test]
fn keys_by_session_cookie_first() { fn unverified_session_cookie_cannot_change_client_key() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(
COOKIE, COOKIE,
@@ -88,19 +120,102 @@ mod tests {
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5")); headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
assert_eq!( assert_eq!(
rate_limit_key(&headers, "/api/auth/login"), client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"session:sess_123" "ip:10.0.0.5"
); );
} }
#[test] #[test]
fn falls_back_to_forwarded_ip() { fn ignores_forwarded_headers_when_untrusted() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
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"
);
}
#[test]
fn prefers_real_ip_when_trusted() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(
"x-forwarded-for", "x-forwarded-for",
HeaderValue::from_static("10.0.0.5, 10.0.0.6"), HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
);
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:10.0.0.9"
);
}
#[test]
fn uses_last_forwarded_hop_when_trusted() {
// A client can prepend spoofed entries; the trusted proxy appends the
// real peer, so the last entry is authoritative.
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
); );
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5"); assert_eq!(
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:10.0.0.6"
);
}
#[test]
fn falls_back_to_peer_ip_without_headers() {
let headers = HeaderMap::new();
assert_eq!(
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:203.0.113.7"
);
}
#[test]
fn ignores_invalid_forwarded_ip_values() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", HeaderValue::from_static("not-an-ip"));
headers.insert(
"x-forwarded-for",
HeaderValue::from_static("198.51.100.8, also-not-an-ip"),
);
assert_eq!(
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:203.0.113.7"
);
}
#[test]
fn falls_back_to_path_without_peer() {
let headers = HeaderMap::new();
assert_eq!(
client_rate_limit_key(&headers, "/api/auth/login", None, &[]),
"anonymous:/api/auth/login"
);
} }
} }
+944
View File
@@ -0,0 +1,944 @@
use std::{
path::PathBuf,
sync::Arc,
time::{Duration, SystemTime},
};
use async_trait::async_trait;
use crank_artifacts::{
ArtifactError, ArtifactStore, ReconciliationCursor, ReconciliationMutation,
ReconciliationNamespace, ReconciliationPresence, ReconciliationRegistration,
ReconciliationScanStop, TempScanCursor,
};
use crank_registry::{
ArtifactClaimFinalization, ArtifactClaimFinalizeOutcome, ArtifactClaimOutcome,
ArtifactClaimRecheckOutcome, ArtifactClaimToken, ArtifactExpiredClaimProbe,
ClaimArtifactReconciliationRequest, ClaimExpiredArtifactReconciliationRequest,
PostgresRegistry,
};
use time::OffsetDateTime;
use tokio::time::MissedTickBehavior;
use tracing::{info, warn};
pub const RECONCILIATION_INTERVAL: Duration = Duration::from_secs(15 * 60);
pub const RECONCILIATION_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
pub const RECONCILIATION_LEASE: Duration = Duration::from_secs(5 * 60);
pub const RECONCILIATION_TRAVERSAL_LIMIT: usize = 4_096;
pub const RECONCILIATION_CANDIDATE_LIMIT: usize = 32;
pub const RECONCILIATION_MUTATION_LIMIT: usize = 32;
pub const RECONCILIATION_TICK_DEADLINE: Duration = Duration::from_secs(30);
pub const TEMP_CLEANUP_GRACE: Duration = Duration::from_secs(60 * 60);
pub const TEMP_CLEANUP_SCAN_LIMIT: usize = 512;
pub const TEMP_CLEANUP_DELETE_LIMIT: usize = 16;
pub const IMPORT_JOB_CLEANUP_LIMIT: u32 = 128;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Phase {
Recovery,
Sweep,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct StepLimits {
traversal_syscalls: usize,
candidates: usize,
mutations: usize,
}
#[derive(Debug)]
struct Step<C> {
cursor: Option<C>,
scan_complete: bool,
traversal_syscalls: usize,
candidates: usize,
mutation_attempts: usize,
classifications: ReconciliationClassificationCounters,
physical_mutation_possible: bool,
retryable: bool,
}
/// Bounded, identity-free classifications observed during one or more scans.
///
/// The fixed fields are the complete telemetry vocabulary: no digest, path,
/// token, source or workspace value can be attached to an item classification.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ReconciliationClassificationCounters {
pub scanner_malformed: usize,
pub scanner_unsafe: usize,
pub recovery_present: usize,
pub recovery_safety: usize,
pub recovery_retryable: usize,
pub registration_integrity: usize,
pub registration_safety: usize,
pub registration_retryable: usize,
pub claim_metadata_conflict: usize,
pub mutation_integrity: usize,
pub mutation_safety: usize,
pub mutation_retryable: usize,
}
impl ReconciliationClassificationCounters {
fn checked_add(self, other: Self) -> Option<Self> {
Some(Self {
scanner_malformed: self
.scanner_malformed
.checked_add(other.scanner_malformed)?,
scanner_unsafe: self.scanner_unsafe.checked_add(other.scanner_unsafe)?,
recovery_present: self.recovery_present.checked_add(other.recovery_present)?,
recovery_safety: self.recovery_safety.checked_add(other.recovery_safety)?,
recovery_retryable: self
.recovery_retryable
.checked_add(other.recovery_retryable)?,
registration_integrity: self
.registration_integrity
.checked_add(other.registration_integrity)?,
registration_safety: self
.registration_safety
.checked_add(other.registration_safety)?,
registration_retryable: self
.registration_retryable
.checked_add(other.registration_retryable)?,
claim_metadata_conflict: self
.claim_metadata_conflict
.checked_add(other.claim_metadata_conflict)?,
mutation_integrity: self
.mutation_integrity
.checked_add(other.mutation_integrity)?,
mutation_safety: self.mutation_safety.checked_add(other.mutation_safety)?,
mutation_retryable: self
.mutation_retryable
.checked_add(other.mutation_retryable)?,
})
}
fn record_registration_error(&mut self, error: ArtifactError) -> bool {
match error {
ArtifactError::Integrity
| ArtifactError::EmptySource
| ArtifactError::SourceTooLarge => {
self.registration_integrity += 1;
false
}
ArtifactError::UnsafeRoot
| ArtifactError::InvalidReference
| ArtifactError::NotFound => {
self.registration_safety += 1;
false
}
ArtifactError::Storage => {
self.registration_retryable += 1;
true
}
}
}
fn record_mutation_error(&mut self, error: ArtifactError) -> bool {
match error {
ArtifactError::Integrity
| ArtifactError::EmptySource
| ArtifactError::SourceTooLarge => self.mutation_integrity += 1,
ArtifactError::UnsafeRoot
| ArtifactError::InvalidReference
| ArtifactError::NotFound => self.mutation_safety += 1,
ArtifactError::Storage => self.mutation_retryable += 1,
}
true
}
fn record_recovery_presence(&mut self, presence: ReconciliationPresence) -> bool {
match presence {
ReconciliationPresence::Final | ReconciliationPresence::Quarantine => {
self.recovery_present += 1;
false
}
ReconciliationPresence::Unsafe => {
self.recovery_safety += 1;
false
}
ReconciliationPresence::Retryable => {
self.recovery_retryable += 1;
true
}
ReconciliationPresence::Absent => false,
}
}
fn record_mutation_presence(&mut self, presence: ReconciliationPresence) -> bool {
match presence {
ReconciliationPresence::Absent => false,
ReconciliationPresence::Unsafe => {
self.mutation_safety += 1;
true
}
ReconciliationPresence::Final
| ReconciliationPresence::Quarantine
| ReconciliationPresence::Retryable => {
self.mutation_retryable += 1;
true
}
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ReconciliationTickReport {
pub traversal_syscalls: usize,
pub candidates: usize,
pub mutation_attempts: usize,
pub classifications: ReconciliationClassificationCounters,
pub recovery_completed: bool,
pub cycle_completed: bool,
pub retryable: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub enum ReconciliationCoordinatorError {
#[error("artifact reconciliation backend is unavailable")]
Backend,
#[error("artifact reconciliation backend exceeded its bounded contract")]
Bounds,
#[error("artifact reconciliation backend exceeded its deadline")]
Deadline,
}
#[async_trait]
trait ReconciliationBackend: Send + Sync {
type Cursor: Send;
async fn step(
&self,
phase: Phase,
cursor: Option<Self::Cursor>,
limits: StepLimits,
) -> Result<Step<Self::Cursor>, ReconciliationCoordinatorError>;
}
struct ReconciliationCoordinator<B: ReconciliationBackend> {
backend: B,
phase: Phase,
cursor: Option<B::Cursor>,
tick_limits: StepLimits,
}
#[derive(Clone)]
struct PostgresReconciliationBackend {
registry: PostgresRegistry,
store: Arc<ArtifactStore>,
}
enum PostgresReconciliationCursor {
ExpiredClaim(ArtifactExpiredClaimProbe),
Filesystem(ReconciliationCursor),
}
impl std::fmt::Debug for PostgresReconciliationCursor {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("PostgresReconciliationCursor(..)")
}
}
impl PostgresReconciliationBackend {
fn new(registry: PostgresRegistry, store: Arc<ArtifactStore>) -> Self {
Self { registry, store }
}
async fn database_now(&self) -> Result<OffsetDateTime, ReconciliationCoordinatorError> {
self.registry
.artifact_reconciliation_now()
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)
}
async fn presence(
&self,
artifact_ref: crank_artifacts::ArtifactRef,
) -> Result<ReconciliationPresence, ReconciliationCoordinatorError> {
let store = Arc::clone(&self.store);
let result =
tokio::task::spawn_blocking(move || store.reconciliation_presence(&artifact_ref))
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
Ok(match result {
Ok(presence) => presence,
Err(ArtifactError::Storage) => ReconciliationPresence::Retryable,
Err(_) => ReconciliationPresence::Unsafe,
})
}
}
fn reconciliation_lease_expires_at(claimed_at: OffsetDateTime) -> OffsetDateTime {
claimed_at
+ time::Duration::seconds(
i64::try_from(RECONCILIATION_LEASE.as_secs())
.expect("the fixed reconciliation lease fits i64"),
)
}
impl<B: ReconciliationBackend> ReconciliationCoordinator<B> {
fn new(backend: B) -> Self {
Self {
backend,
phase: Phase::Recovery,
cursor: None,
tick_limits: StepLimits {
traversal_syscalls: RECONCILIATION_TRAVERSAL_LIMIT,
candidates: RECONCILIATION_CANDIDATE_LIMIT,
mutations: RECONCILIATION_MUTATION_LIMIT,
},
}
}
#[cfg(test)]
fn with_tick_limits(backend: B, tick_limits: StepLimits) -> Self {
Self {
backend,
phase: Phase::Recovery,
cursor: None,
tick_limits,
}
}
async fn tick(&mut self) -> Result<ReconciliationTickReport, ReconciliationCoordinatorError> {
let mut report = ReconciliationTickReport::default();
loop {
let limits = StepLimits {
traversal_syscalls: self
.tick_limits
.traversal_syscalls
.saturating_sub(report.traversal_syscalls),
candidates: self
.tick_limits
.candidates
.saturating_sub(report.candidates),
mutations: self
.tick_limits
.mutations
.saturating_sub(report.mutation_attempts),
};
if limits.traversal_syscalls == 0 || limits.candidates == 0 || limits.mutations == 0 {
break;
}
let step = match self
.backend
.step(self.phase, self.cursor.take(), limits)
.await
{
Ok(step) => step,
Err(error) => {
self.cursor = None;
self.phase = Phase::Recovery;
return Err(error);
}
};
if step.traversal_syscalls > limits.traversal_syscalls
|| step.candidates > limits.candidates
|| step.mutation_attempts > limits.mutations
|| step.mutation_attempts > step.candidates
{
self.cursor = None;
self.phase = Phase::Recovery;
return Err(ReconciliationCoordinatorError::Bounds);
}
report.traversal_syscalls += step.traversal_syscalls;
report.candidates += step.candidates;
report.mutation_attempts += step.mutation_attempts;
report.classifications = report
.classifications
.checked_add(step.classifications)
.ok_or_else(|| {
self.cursor = None;
self.phase = Phase::Recovery;
ReconciliationCoordinatorError::Bounds
})?;
report.retryable |= step.retryable;
let made_progress =
step.traversal_syscalls != 0 || step.candidates != 0 || step.mutation_attempts != 0;
if step.physical_mutation_possible {
// A cursor is valid only while the namespace is unchanged. A
// possible rename/unlink includes ambiguous fsync outcomes.
drop(step.cursor);
self.cursor = None;
self.phase = Phase::Recovery;
} else {
self.cursor = step.cursor;
if step.scan_complete {
self.cursor = None;
match self.phase {
Phase::Recovery => {
report.recovery_completed = true;
self.phase = Phase::Sweep;
}
Phase::Sweep => {
report.cycle_completed = true;
self.phase = Phase::Recovery;
break;
}
}
}
}
if step.retryable || (!made_progress && !step.scan_complete) {
break;
}
}
Ok(report)
}
}
#[async_trait]
impl ReconciliationBackend for PostgresReconciliationBackend {
type Cursor = PostgresReconciliationCursor;
async fn step(
&self,
phase: Phase,
cursor: Option<Self::Cursor>,
limits: StepLimits,
) -> Result<Step<Self::Cursor>, ReconciliationCoordinatorError> {
let mut recovered_candidates = 0;
let mut classifications = ReconciliationClassificationCounters::default();
let mut recovery_retryable = false;
let mut filesystem_cursor = None;
if phase == Phase::Recovery {
let after = match cursor {
Some(PostgresReconciliationCursor::ExpiredClaim(probe)) => Some(probe),
Some(PostgresReconciliationCursor::Filesystem(cursor)) => {
filesystem_cursor = Some(cursor);
None
}
None => None,
};
if filesystem_cursor.is_none() {
let database_now = self.database_now().await?;
let probe_limit = u32::try_from(limits.candidates).unwrap_or(u32::MAX);
let probes = self
.registry
.list_expired_artifact_reconciliation_probes_after(
database_now,
after.as_ref(),
probe_limit,
)
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
let page_full = probes.len() == probe_limit as usize;
let mut last_processed = after;
for probe in probes {
recovered_candidates += 1;
last_processed = Some(probe.clone());
let presence = self.presence(probe.artifact_ref().clone()).await?;
if presence != ReconciliationPresence::Absent {
recovery_retryable |= classifications.record_recovery_presence(presence);
continue;
}
let claimed_at = self.database_now().await?;
let claim = self
.registry
.claim_expired_artifact_reconciliation(
&probe,
ClaimExpiredArtifactReconciliationRequest {
token: ArtifactClaimToken::generate(),
claimed_at,
lease_expires_at: reconciliation_lease_expires_at(claimed_at),
},
)
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
let Some(claim) = claim else {
continue;
};
// Recheck after the DB CAS. A concurrent filesystem put
// does not consult PostgreSQL, so the first probe alone
// cannot prove absence at finalization time.
let presence = self.presence(claim.artifact_ref().clone()).await?;
if presence != ReconciliationPresence::Absent {
classifications.record_recovery_presence(presence);
recovery_retryable = true;
continue;
}
let recovered_at = self.database_now().await?;
let result = self
.registry
.recover_artifact_reconciliation_claim(
&claim,
ArtifactClaimFinalization::AlreadyAbsent,
recovered_at,
)
.await;
let recovered = matches!(result, Ok(ArtifactClaimFinalizeOutcome::Unavailable));
if recovered {
let presence = self.presence(claim.artifact_ref().clone()).await?;
if presence != ReconciliationPresence::Absent {
classifications.record_recovery_presence(presence);
recovery_retryable = true;
}
}
return Ok(Step {
cursor: last_processed.map(PostgresReconciliationCursor::ExpiredClaim),
scan_complete: false,
traversal_syscalls: 0,
candidates: recovered_candidates,
mutation_attempts: 1,
classifications,
physical_mutation_possible: false,
retryable: recovery_retryable || !recovered,
});
}
if page_full {
return Ok(Step {
cursor: last_processed.map(PostgresReconciliationCursor::ExpiredClaim),
scan_complete: false,
traversal_syscalls: 0,
candidates: recovered_candidates,
mutation_attempts: 0,
classifications,
physical_mutation_possible: false,
retryable: recovery_retryable,
});
}
}
} else if let Some(PostgresReconciliationCursor::Filesystem(cursor)) = cursor {
filesystem_cursor = Some(cursor);
}
let scan_candidate_limit = limits.candidates.saturating_sub(recovered_candidates);
if scan_candidate_limit == 0 {
return Ok(Step {
cursor: filesystem_cursor.map(PostgresReconciliationCursor::Filesystem),
scan_complete: false,
traversal_syscalls: 0,
candidates: recovered_candidates,
mutation_attempts: 0,
classifications,
physical_mutation_possible: false,
retryable: recovery_retryable,
});
}
let namespace = match phase {
Phase::Recovery => ReconciliationNamespace::Quarantine,
Phase::Sweep => ReconciliationNamespace::Final,
};
let store = Arc::clone(&self.store);
let (mut scan, candidates) = tokio::task::spawn_blocking(move || {
store.scan_reconciliation_namespace(
namespace,
filesystem_cursor,
limits.traversal_syscalls,
scan_candidate_limit,
)
})
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)?
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
let mut step = Step {
cursor: scan
.continuation
.take()
.map(PostgresReconciliationCursor::Filesystem),
scan_complete: scan.stop == ReconciliationScanStop::Complete,
traversal_syscalls: scan.traversal_syscalls,
candidates: recovered_candidates,
mutation_attempts: 0,
classifications: classifications
.checked_add(ReconciliationClassificationCounters {
scanner_malformed: scan.malformed,
scanner_unsafe: scan.unsafe_entries,
..ReconciliationClassificationCounters::default()
})
.ok_or(ReconciliationCoordinatorError::Bounds)?,
physical_mutation_possible: false,
retryable: recovery_retryable || scan.stop == ReconciliationScanStop::Retryable,
};
for candidate in candidates {
if step.candidates == limits.candidates || step.mutation_attempts == limits.mutations {
break;
}
step.candidates += 1;
let registration_grace = match phase {
Phase::Recovery => Duration::ZERO,
Phase::Sweep => RECONCILIATION_GRACE,
};
let store = Arc::clone(&self.store);
let registration = tokio::task::spawn_blocking(move || {
let registration = store.register_reconciliation(
&candidate,
registration_grace,
SystemTime::now(),
);
(candidate, registration)
})
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
let (candidate, registration) = registration;
let artifact = match registration {
Ok(ReconciliationRegistration::Registered(artifact)) => artifact,
Ok(ReconciliationRegistration::NotEligible) => continue,
Err(error) => {
step.retryable |= step.classifications.record_registration_error(error);
continue;
}
};
let claimed_at = self.database_now().await?;
let lease_expires_at = reconciliation_lease_expires_at(claimed_at);
let detached_grace = time::Duration::seconds(
i64::try_from(RECONCILIATION_GRACE.as_secs())
.expect("the fixed reconciliation grace fits i64"),
);
let claim = self
.registry
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
artifact: &artifact,
token: ArtifactClaimToken::generate(),
claimed_at,
lease_expires_at,
detached_grace,
})
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
let claim = match claim {
ArtifactClaimOutcome::Claimed(claim) => claim,
ArtifactClaimOutcome::HeldByOther
| ArtifactClaimOutcome::ActiveReference
| ArtifactClaimOutcome::DetachedReferenceInGrace => continue,
ArtifactClaimOutcome::MetadataConflict => {
step.classifications.claim_metadata_conflict += 1;
continue;
}
};
let rechecked_at = self.database_now().await?;
let recheck = self
.registry
.recheck_artifact_reconciliation_claim(&claim, rechecked_at, detached_grace)
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
if recheck != ArtifactClaimRecheckOutcome::Mutate {
continue;
}
step.mutation_attempts += 1;
step.physical_mutation_possible = true;
let store = Arc::clone(&self.store);
let mutation = match tokio::task::spawn_blocking(move || match phase {
Phase::Recovery => store.delete_quarantined_reconciliation(candidate),
Phase::Sweep => store.quarantine_reconciliation(candidate),
})
.await
{
Ok(mutation) => mutation,
Err(_) => Err(ArtifactError::Storage),
};
let mut observation = match mutation {
Ok(ReconciliationMutation::Quarantined)
| Ok(ReconciliationMutation::AlreadyQuarantined) => {
ArtifactClaimFinalization::Quarantined
}
Ok(ReconciliationMutation::Deleted) => ArtifactClaimFinalization::Deleted,
Ok(ReconciliationMutation::AlreadyAbsent) => {
ArtifactClaimFinalization::AlreadyAbsent
}
Ok(ReconciliationMutation::Retryable) => {
step.classifications.mutation_retryable += 1;
step.retryable = true;
ArtifactClaimFinalization::Retryable
}
Err(error) => {
step.retryable |= step.classifications.record_mutation_error(error);
ArtifactClaimFinalization::Retryable
}
};
if matches!(
observation,
ArtifactClaimFinalization::Deleted | ArtifactClaimFinalization::AlreadyAbsent
) {
// `unavailable` means both canonical locations are confirmed
// absent. A concurrent republish can recreate final bytes
// without consulting PostgreSQL, so unlink alone is not proof.
let presence = self.presence(claim.artifact_ref().clone()).await?;
observation = match presence {
ReconciliationPresence::Absent => ArtifactClaimFinalization::AlreadyAbsent,
presence => {
step.retryable |= step.classifications.record_mutation_presence(presence);
ArtifactClaimFinalization::Retryable
}
};
}
let observed_at = self.database_now().await?;
let finalization = match phase {
Phase::Recovery => {
self.registry
.recover_artifact_reconciliation_claim(&claim, observation, observed_at)
.await
}
Phase::Sweep => {
self.registry
.finalize_artifact_reconciliation_claim(&claim, observation, observed_at)
.await
}
};
let finalized_as_expected =
match observation {
ArtifactClaimFinalization::Deleted
| ArtifactClaimFinalization::AlreadyAbsent => matches!(
finalization.as_ref(),
Ok(ArtifactClaimFinalizeOutcome::Unavailable)
),
ArtifactClaimFinalization::Quarantined
| ArtifactClaimFinalization::Retryable => matches!(
finalization.as_ref(),
Ok(ArtifactClaimFinalizeOutcome::Retained)
),
};
if !finalized_as_expected {
step.retryable = true;
}
if matches!(
finalization.as_ref(),
Ok(ArtifactClaimFinalizeOutcome::Unavailable)
) {
let presence = self.presence(claim.artifact_ref().clone()).await?;
step.retryable |= step.classifications.record_mutation_presence(presence);
}
// The scan cursor and every remaining observation came from the
// pre-mutation namespace. Return immediately so the coordinator
// drops them and restarts a recovery cycle from `None`.
break;
}
Ok(step)
}
}
/// Opens the protected root without running filesystem syscalls on Tokio's
/// async executor.
pub async fn open_reconciliation_store(
root: PathBuf,
) -> Result<Arc<ArtifactStore>, ReconciliationCoordinatorError> {
tokio::task::spawn_blocking(move || ArtifactStore::open(root).map(Arc::new))
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)?
.map_err(|_| ReconciliationCoordinatorError::Backend)
}
/// Runs the immediate bounded startup cycle, then schedules 15-minute ticks.
/// Recovery remains ahead of the final sweep even when it spans several ticks.
pub async fn spawn_artifact_reconciliation(registry: PostgresRegistry, store: Arc<ArtifactStore>) {
let cleanup_registry = registry.clone();
let backend = PostgresReconciliationBackend::new(registry, Arc::clone(&store));
let mut coordinator = ReconciliationCoordinator::new(backend);
let mut temp_cursor = None;
let startup_maintenance = async {
observe_import_job_cleanup(&cleanup_registry).await;
observe_tick(run_bounded_reconciliation_tick(&mut coordinator).await);
temp_cursor = observe_stale_temp_cleanup(Arc::clone(&store), None).await;
};
if tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, startup_maintenance)
.await
.is_err()
{
warn!(
name: "admin.artifact_maintenance.startup_deadline",
"startup maintenance exceeded its shared deadline; remaining work will resume in background"
);
}
tokio::spawn(async move {
let mut interval = tokio::time::interval(RECONCILIATION_INTERVAL);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
// The immediate tick was run above as part of startup composition.
interval.tick().await;
loop {
interval.tick().await;
observe_import_job_cleanup(&cleanup_registry).await;
observe_tick(run_bounded_reconciliation_tick(&mut coordinator).await);
temp_cursor = observe_stale_temp_cleanup(Arc::clone(&store), temp_cursor).await;
}
});
}
async fn observe_import_job_cleanup(registry: &PostgresRegistry) {
let cleanup = async {
let mut deleted_jobs = 0_u64;
let mut detached_sources = 0_u64;
loop {
let report = registry
.cleanup_expired_import_jobs(IMPORT_JOB_CLEANUP_LIMIT)
.await?;
deleted_jobs = deleted_jobs.saturating_add(report.deleted_jobs);
detached_sources = detached_sources.saturating_add(report.detached_sources);
if !report.more_work {
return Ok::<_, crank_registry::RegistryError>((deleted_jobs, detached_sources));
}
// Each pass commits independently. Yield before requesting the
// next lock batch so normal import traffic can make progress.
tokio::task::yield_now().await;
}
};
match tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, cleanup).await {
Ok(Ok((deleted_jobs, detached_sources))) => info!(
name: "admin.openapi_import_cleanup.tick",
deleted_jobs,
detached_sources,
more_work = false,
"OpenAPI import cleanup tick completed"
),
Ok(Err(_)) => warn!(
name: "admin.openapi_import_cleanup.failed",
error_category = "registry",
"OpenAPI import cleanup tick will be retried"
),
Err(_) => warn!(
name: "admin.openapi_import_cleanup.failed",
error_category = "deadline",
"OpenAPI import cleanup tick exceeded its deadline"
),
}
}
async fn run_bounded_reconciliation_tick<B: ReconciliationBackend>(
coordinator: &mut ReconciliationCoordinator<B>,
) -> Result<ReconciliationTickReport, ReconciliationCoordinatorError> {
tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, coordinator.tick())
.await
.map_err(|_| ReconciliationCoordinatorError::Deadline)?
}
async fn observe_stale_temp_cleanup(
store: Arc<ArtifactStore>,
cursor: Option<TempScanCursor>,
) -> Option<TempScanCursor> {
let progress = Arc::new(std::sync::Mutex::new(cursor));
let cleanup_progress = Arc::clone(&progress);
let cleanup = async move {
let mut scanned = 0_usize;
let mut omitted = 0_usize;
let mut deleted = 0_usize;
let mut retryable = 0_usize;
loop {
let scanner = Arc::clone(&store);
let cursor = cleanup_progress
.lock()
.map_err(|_| ArtifactError::Storage)?
.clone();
let (scan, candidates, next_cursor) = tokio::task::spawn_blocking(move || {
scanner.scan_stale_temps_after(
TEMP_CLEANUP_GRACE,
cursor,
TEMP_CLEANUP_SCAN_LIMIT,
TEMP_CLEANUP_DELETE_LIMIT,
)
})
.await
.map_err(|_| ArtifactError::Storage)??;
scanned = scanned.saturating_add(scan.scanned);
omitted = omitted.saturating_add(scan.omitted);
*cleanup_progress
.lock()
.map_err(|_| ArtifactError::Storage)? = Some(next_cursor);
for candidate in candidates {
let store = Arc::clone(&store);
match tokio::task::spawn_blocking(move || store.delete_stale_temp(candidate)).await
{
Ok(Ok(())) => deleted += 1,
Ok(Err(_)) | Err(_) => retryable += 1,
}
}
if scan.complete {
return Ok::<_, ArtifactError>((
scanned,
omitted,
deleted,
retryable,
cleanup_progress
.lock()
.map_err(|_| ArtifactError::Storage)?
.clone(),
));
}
tokio::task::yield_now().await;
}
};
match tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, cleanup).await {
Ok(Ok((scanned, omitted, deleted, retryable, next_cursor))) => {
info!(
name: "admin.artifact_temp_cleanup.tick",
scanned,
omitted,
deleted,
retryable,
"artifact temporary-file cleanup tick completed"
);
next_cursor
}
Ok(Err(_)) => {
warn!(
name: "admin.artifact_temp_cleanup.failed",
error_category = "backend",
"artifact temporary-file cleanup tick will be retried"
);
progress.lock().ok().and_then(|cursor| cursor.clone())
}
Err(_) => {
warn!(
name: "admin.artifact_temp_cleanup.failed",
error_category = "deadline",
"artifact temporary-file cleanup tick exceeded its deadline"
);
progress.lock().ok().and_then(|cursor| cursor.clone())
}
}
}
fn observe_tick(result: Result<ReconciliationTickReport, ReconciliationCoordinatorError>) {
match result {
Ok(report) => info!(
name: "admin.artifact_reconciliation.tick",
traversal_syscalls = report.traversal_syscalls,
candidates = report.candidates,
mutation_attempts = report.mutation_attempts,
scanner_malformed = report.classifications.scanner_malformed,
scanner_unsafe = report.classifications.scanner_unsafe,
recovery_present = report.classifications.recovery_present,
recovery_safety = report.classifications.recovery_safety,
recovery_retryable = report.classifications.recovery_retryable,
registration_integrity = report.classifications.registration_integrity,
registration_safety = report.classifications.registration_safety,
registration_retryable = report.classifications.registration_retryable,
claim_metadata_conflict = report.classifications.claim_metadata_conflict,
mutation_integrity = report.classifications.mutation_integrity,
mutation_safety = report.classifications.mutation_safety,
mutation_retryable = report.classifications.mutation_retryable,
recovery_completed = report.recovery_completed,
cycle_completed = report.cycle_completed,
retryable = report.retryable,
"artifact reconciliation tick completed"
),
Err(error) => warn!(
name: "admin.artifact_reconciliation.failed",
error_category = match error {
ReconciliationCoordinatorError::Backend => "backend",
ReconciliationCoordinatorError::Bounds => "bounds",
ReconciliationCoordinatorError::Deadline => "deadline",
},
"artifact reconciliation tick will be retried"
),
}
}
#[cfg(test)]
#[path = "reconciliation/tests.rs"]
mod tests;
+690
View File
@@ -0,0 +1,690 @@
use std::{
collections::VecDeque,
fs,
os::unix::fs::{PermissionsExt, symlink},
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
};
use async_trait::async_trait;
use crank_artifacts::{ReconciliationRegistration, RegisteredArtifact};
use crank_registry::MigrationAuthority;
use super::*;
static NEXT_TEST_ROOT: AtomicUsize = AtomicUsize::new(0);
struct TestRoot(PathBuf);
impl TestRoot {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"crank-admin-reconciliation-{}-{}",
std::process::id(),
NEXT_TEST_ROOT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
Self(path)
}
}
impl Drop for TestRoot {
fn drop(&mut self) {
let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700));
let _ = fs::remove_dir_all(&self.0);
}
}
fn artifact_path(root: &Path, artifact: &RegisteredArtifact) -> PathBuf {
let digest = artifact.artifact_ref().digest_hex();
root.join("sha256").join(&digest[..2]).join(digest)
}
fn age_artifact(root: &Path, artifact: &RegisteredArtifact) {
fs::File::open(artifact_path(root, artifact))
.unwrap()
.set_modified(SystemTime::now() - RECONCILIATION_GRACE - Duration::from_secs(60))
.unwrap();
}
fn remove_registered_artifact(store: &ArtifactStore, artifact: &RegisteredArtifact) {
let (_, candidates) = store
.scan_reconciliation_namespace(ReconciliationNamespace::Final, None, 4_096, 32)
.unwrap();
let candidate = candidates
.into_iter()
.find(|candidate| {
matches!(
store.register_reconciliation(candidate, Duration::ZERO, SystemTime::now()),
Ok(ReconciliationRegistration::Registered(found)) if found == *artifact
)
})
.expect("the final artifact is discoverable");
assert!(matches!(
store.quarantine_reconciliation(candidate),
Ok(ReconciliationMutation::Quarantined | ReconciliationMutation::AlreadyQuarantined)
));
let (_, candidates) = store
.scan_reconciliation_namespace(ReconciliationNamespace::Quarantine, None, 4_096, 32)
.unwrap();
let candidate = candidates
.into_iter()
.find(|candidate| {
matches!(
store.register_reconciliation(candidate, Duration::ZERO, SystemTime::now()),
Ok(ReconciliationRegistration::Registered(found)) if found == *artifact
)
})
.expect("the quarantined artifact is discoverable");
assert!(matches!(
store.delete_quarantined_reconciliation(candidate),
Ok(ReconciliationMutation::Deleted | ReconciliationMutation::AlreadyAbsent)
));
}
#[derive(Clone, Copy)]
struct StepTemplate {
scan_complete: bool,
traversal_syscalls: usize,
candidates: usize,
mutation_attempts: usize,
classifications: ReconciliationClassificationCounters,
physical_mutation_possible: bool,
retryable: bool,
return_cursor: bool,
error: bool,
}
impl StepTemplate {
fn complete() -> Self {
Self {
scan_complete: true,
traversal_syscalls: 1,
candidates: 0,
mutation_attempts: 0,
classifications: ReconciliationClassificationCounters::default(),
physical_mutation_possible: false,
retryable: false,
return_cursor: false,
error: false,
}
}
}
struct CursorEvidence {
alive: Arc<AtomicUsize>,
}
impl CursorEvidence {
fn new(alive: Arc<AtomicUsize>, maximum: &AtomicUsize) -> Self {
let current = alive.fetch_add(1, Ordering::SeqCst) + 1;
maximum.fetch_max(current, Ordering::SeqCst);
Self { alive }
}
}
impl Drop for CursorEvidence {
fn drop(&mut self) {
self.alive.fetch_sub(1, Ordering::SeqCst);
}
}
struct ScriptedBackend {
steps: Mutex<VecDeque<StepTemplate>>,
calls: Mutex<Vec<(Phase, bool, StepLimits)>>,
alive: Arc<AtomicUsize>,
maximum: AtomicUsize,
}
impl ScriptedBackend {
fn new(steps: impl IntoIterator<Item = StepTemplate>) -> Self {
Self {
steps: Mutex::new(steps.into_iter().collect()),
calls: Mutex::new(Vec::new()),
alive: Arc::new(AtomicUsize::new(0)),
maximum: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl ReconciliationBackend for Arc<ScriptedBackend> {
type Cursor = CursorEvidence;
async fn step(
&self,
phase: Phase,
cursor: Option<Self::Cursor>,
limits: StepLimits,
) -> Result<Step<Self::Cursor>, ReconciliationCoordinatorError> {
self.calls
.lock()
.unwrap()
.push((phase, cursor.is_some(), limits));
drop(cursor);
let template = self.steps.lock().unwrap().pop_front().unwrap();
if template.error {
return Err(ReconciliationCoordinatorError::Backend);
}
let cursor = template
.return_cursor
.then(|| CursorEvidence::new(self.alive.clone(), &self.maximum));
Ok(Step {
cursor,
scan_complete: template.scan_complete,
traversal_syscalls: template.traversal_syscalls,
candidates: template.candidates,
mutation_attempts: template.mutation_attempts,
classifications: template.classifications,
physical_mutation_possible: template.physical_mutation_possible,
retryable: template.retryable,
})
}
}
#[tokio::test]
async fn recovery_always_completes_before_final_sweep() {
let backend = Arc::new(ScriptedBackend::new([
StepTemplate::complete(),
StepTemplate::complete(),
]));
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
let report = coordinator.tick().await.unwrap();
assert!(report.recovery_completed);
assert!(report.cycle_completed);
let phases = backend
.calls
.lock()
.unwrap()
.iter()
.map(|call| call.0)
.collect::<Vec<_>>();
assert_eq!(phases, vec![Phase::Recovery, Phase::Sweep]);
}
#[tokio::test]
async fn possible_mutation_discards_cursor_and_restarts_recovery() {
let paged = StepTemplate {
scan_complete: false,
traversal_syscalls: 2,
candidates: 1,
mutation_attempts: 0,
classifications: ReconciliationClassificationCounters::default(),
physical_mutation_possible: false,
retryable: true,
return_cursor: true,
error: false,
};
let mutation = StepTemplate {
scan_complete: false,
traversal_syscalls: 1,
candidates: 1,
mutation_attempts: 1,
classifications: ReconciliationClassificationCounters::default(),
physical_mutation_possible: true,
retryable: false,
return_cursor: true,
error: false,
};
let backend = Arc::new(ScriptedBackend::new([
StepTemplate::complete(),
paged,
mutation,
StepTemplate::complete(),
StepTemplate::complete(),
]));
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
let first = coordinator.tick().await.unwrap();
assert!(first.retryable);
assert_eq!(backend.alive.load(Ordering::SeqCst), 1);
let second = coordinator.tick().await.unwrap();
assert!(second.cycle_completed);
let calls = backend.calls.lock().unwrap();
assert_eq!(
calls
.iter()
.map(|(phase, had_cursor, _)| (*phase, *had_cursor))
.collect::<Vec<_>>(),
vec![
(Phase::Recovery, false),
(Phase::Sweep, false),
(Phase::Sweep, true),
(Phase::Recovery, false),
(Phase::Sweep, false),
]
);
assert_eq!(backend.alive.load(Ordering::SeqCst), 0);
assert_eq!(backend.maximum.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn backend_error_discards_cursor_and_restarts_recovery() {
let paged = StepTemplate {
scan_complete: false,
traversal_syscalls: 1,
candidates: 1,
mutation_attempts: 0,
classifications: ReconciliationClassificationCounters::default(),
physical_mutation_possible: false,
retryable: true,
return_cursor: true,
error: false,
};
let failure = StepTemplate {
error: true,
..StepTemplate::complete()
};
let backend = Arc::new(ScriptedBackend::new([
StepTemplate::complete(),
paged,
failure,
StepTemplate::complete(),
StepTemplate::complete(),
]));
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
assert!(coordinator.tick().await.unwrap().retryable);
assert_eq!(backend.alive.load(Ordering::SeqCst), 1);
assert_eq!(
coordinator.tick().await,
Err(ReconciliationCoordinatorError::Backend)
);
assert_eq!(backend.alive.load(Ordering::SeqCst), 0);
assert!(coordinator.tick().await.unwrap().cycle_completed);
let calls = backend.calls.lock().unwrap();
assert_eq!(
calls
.iter()
.map(|(phase, had_cursor, _)| (*phase, *had_cursor))
.collect::<Vec<_>>(),
vec![
(Phase::Recovery, false),
(Phase::Sweep, false),
(Phase::Sweep, true),
(Phase::Recovery, false),
(Phase::Sweep, false),
]
);
}
#[tokio::test]
async fn backend_cannot_exceed_tick_limits() {
let backend = Arc::new(ScriptedBackend::new([StepTemplate {
scan_complete: false,
traversal_syscalls: RECONCILIATION_TRAVERSAL_LIMIT + 1,
candidates: 0,
mutation_attempts: 0,
classifications: ReconciliationClassificationCounters::default(),
physical_mutation_possible: false,
retryable: false,
return_cursor: false,
error: false,
}]));
let mut coordinator = ReconciliationCoordinator::new(backend);
assert_eq!(
coordinator.tick().await,
Err(ReconciliationCoordinatorError::Bounds)
);
}
#[tokio::test]
async fn tick_stops_at_the_exact_candidate_and_mutation_limits() {
let backend = Arc::new(ScriptedBackend::new(
(0..RECONCILIATION_MUTATION_LIMIT).map(|_| StepTemplate {
scan_complete: false,
traversal_syscalls: 1,
candidates: 1,
mutation_attempts: 1,
classifications: ReconciliationClassificationCounters::default(),
physical_mutation_possible: true,
retryable: false,
return_cursor: true,
error: false,
}),
));
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
let report = coordinator.tick().await.unwrap();
assert_eq!(report.candidates, RECONCILIATION_CANDIDATE_LIMIT);
assert_eq!(report.mutation_attempts, RECONCILIATION_MUTATION_LIMIT);
assert_eq!(report.traversal_syscalls, RECONCILIATION_MUTATION_LIMIT);
assert_eq!(
backend.calls.lock().unwrap().len(),
RECONCILIATION_MUTATION_LIMIT
);
assert_eq!(backend.alive.load(Ordering::SeqCst), 0);
assert_eq!(backend.maximum.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn coordinator_retains_at_most_one_cursor_between_ticks() {
let backend = Arc::new(ScriptedBackend::new((0..128).map(|_| StepTemplate {
scan_complete: false,
traversal_syscalls: 1,
candidates: 0,
mutation_attempts: 0,
classifications: ReconciliationClassificationCounters::default(),
physical_mutation_possible: false,
retryable: true,
return_cursor: true,
error: false,
})));
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
for _ in 0..128 {
coordinator.tick().await.unwrap();
assert_eq!(backend.alive.load(Ordering::SeqCst), 1);
}
assert_eq!(backend.maximum.load(Ordering::SeqCst), 1);
drop(coordinator);
assert_eq!(backend.alive.load(Ordering::SeqCst), 0);
}
#[test]
fn registration_failures_have_a_closed_redacted_classification() {
let mut counters = ReconciliationClassificationCounters::default();
assert!(!counters.record_registration_error(ArtifactError::Integrity));
assert!(!counters.record_registration_error(ArtifactError::UnsafeRoot));
assert!(counters.record_registration_error(ArtifactError::Storage));
assert!(!counters.record_registration_error(ArtifactError::NotFound));
assert!(!counters.record_registration_error(ArtifactError::InvalidReference));
assert!(!counters.record_registration_error(ArtifactError::EmptySource));
assert!(!counters.record_registration_error(ArtifactError::SourceTooLarge));
assert_eq!(
counters,
ReconciliationClassificationCounters {
scanner_malformed: 0,
scanner_unsafe: 0,
recovery_present: 0,
recovery_safety: 0,
recovery_retryable: 0,
registration_integrity: 3,
registration_safety: 3,
registration_retryable: 1,
claim_metadata_conflict: 0,
mutation_integrity: 0,
mutation_safety: 0,
mutation_retryable: 0,
}
);
let debug = format!("{counters:?}");
assert!(!debug.contains("sha256:"));
assert!(!debug.contains('/'));
}
#[test]
fn mutation_failures_are_classified_and_always_retryable() {
let mut counters = ReconciliationClassificationCounters::default();
assert!(counters.record_mutation_error(ArtifactError::Integrity));
assert!(counters.record_mutation_error(ArtifactError::UnsafeRoot));
assert!(counters.record_mutation_error(ArtifactError::Storage));
assert!(counters.record_mutation_error(ArtifactError::NotFound));
assert!(counters.record_mutation_error(ArtifactError::InvalidReference));
assert!(counters.record_mutation_error(ArtifactError::EmptySource));
assert!(counters.record_mutation_error(ArtifactError::SourceTooLarge));
assert_eq!(counters.mutation_integrity, 3);
assert_eq!(counters.mutation_safety, 3);
assert_eq!(counters.mutation_retryable, 1);
}
#[tokio::test]
async fn production_backend_recovers_absence_before_sweep_and_restarts_after_mutation() {
let database_url =
crank_test_support::postgres_schema_url("admin_reconciliation_backend").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
let root = TestRoot::new();
let store = ArtifactStore::open(&root.0).unwrap();
let now = OffsetDateTime::now_utc();
let expired = store.put_registered(b"expired physical absence\n").unwrap();
let expired_claim = registry
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
artifact: &expired,
token: ArtifactClaimToken::generate(),
claimed_at: now - time::Duration::minutes(10),
lease_expires_at: now - time::Duration::minutes(5),
detached_grace: time::Duration::hours(24),
})
.await
.unwrap();
assert!(matches!(expired_claim, ArtifactClaimOutcome::Claimed(_)));
remove_registered_artifact(&store, &expired);
sqlx::query(
"update artifact_blobs
set claim_expires_at = clock_timestamp() - interval '1 second'
where digest = $1",
)
.bind(expired.artifact_ref().digest_hex())
.execute(registry.pool())
.await
.unwrap();
let corrupted = store
.put_registered(b"integrity-blocked-candidate\n")
.unwrap();
let corrupted_path = artifact_path(&root.0, &corrupted);
let corrupted_size = fs::metadata(&corrupted_path).unwrap().len() as usize;
fs::set_permissions(&corrupted_path, fs::Permissions::from_mode(0o600)).unwrap();
fs::write(&corrupted_path, vec![b'x'; corrupted_size]).unwrap();
fs::set_permissions(&corrupted_path, fs::Permissions::from_mode(0o400)).unwrap();
age_artifact(&root.0, &corrupted);
let later = store.put_registered(b"later-valid-candidate\n").unwrap();
age_artifact(&root.0, &later);
let metadata_conflict = store
.put_registered(b"metadata-conflict-candidate\n")
.unwrap();
age_artifact(&root.0, &metadata_conflict);
sqlx::query(
"insert into artifact_blobs
(digest, artifact_ref, size_bytes, storage_lifecycle, created_at, updated_at)
values ($1, $2, $3, 'available', $4, $4)",
)
.bind(metadata_conflict.artifact_ref().digest_hex())
.bind(metadata_conflict.artifact_ref().as_str())
.bind(i64::try_from(metadata_conflict.size_bytes()).unwrap() + 1)
.bind(now)
.execute(registry.pool())
.await
.unwrap();
let shard = artifact_path(&root.0, &later)
.parent()
.unwrap()
.to_path_buf();
fs::write(shard.join("not-a-digest"), b"malformed").unwrap();
let unsafe_name = format!(
"{}{}",
&later.artifact_ref().digest_hex()[..2],
"f".repeat(62)
);
assert_ne!(unsafe_name, later.artifact_ref().digest_hex());
symlink("not-a-digest", shard.join(unsafe_name)).unwrap();
let backend = PostgresReconciliationBackend::new(registry.clone(), Arc::new(store.clone()));
let mut coordinator = ReconciliationCoordinator::with_tick_limits(
backend,
StepLimits {
traversal_syscalls: RECONCILIATION_TRAVERSAL_LIMIT,
candidates: RECONCILIATION_CANDIDATE_LIMIT,
mutations: 1,
},
);
// The single mutation slot is consumed by expired-claim recovery. The old
// final candidate proves Sweep did not run ahead of Recovery.
let first = coordinator.tick().await.unwrap();
assert_eq!(first.mutation_attempts, 1);
assert!(!first.recovery_completed);
assert_eq!(
store.reconciliation_presence(later.artifact_ref()).unwrap(),
ReconciliationPresence::Final
);
let lifecycle: String =
sqlx::query_scalar("select storage_lifecycle from artifact_blobs where digest = $1")
.bind(expired.artifact_ref().digest_hex())
.fetch_one(registry.pool())
.await
.unwrap();
assert_eq!(lifecycle, "unavailable");
// The real final sweep classifies blocked entries and still reaches the
// later valid item. Its physical mutation invalidates the final cursor.
let second = coordinator.tick().await.unwrap();
assert!(second.recovery_completed);
assert_eq!(second.mutation_attempts, 1);
assert_eq!(
store.reconciliation_presence(later.artifact_ref()).unwrap(),
ReconciliationPresence::Quarantine
);
// Model the next scheduled/restarted lease window. Because the coordinator
// reset to Recovery and discarded its final cursor, quarantine is handled
// before another final sweep.
sqlx::query("update artifact_blobs set claim_expires_at = $1 where digest = $2")
.bind(OffsetDateTime::now_utc() - time::Duration::seconds(1))
.bind(later.artifact_ref().digest_hex())
.execute(registry.pool())
.await
.unwrap();
let third = coordinator.tick().await.unwrap();
assert_eq!(third.mutation_attempts, 1);
assert_eq!(
store.reconciliation_presence(later.artifact_ref()).unwrap(),
ReconciliationPresence::Absent
);
let fourth = coordinator.tick().await.unwrap();
assert!(fourth.cycle_completed);
let classifications = first
.classifications
.checked_add(second.classifications)
.and_then(|value| value.checked_add(third.classifications))
.and_then(|value| value.checked_add(fourth.classifications))
.unwrap();
assert!(classifications.scanner_malformed > 0);
assert!(classifications.scanner_unsafe > 0);
assert!(classifications.registration_integrity > 0);
assert!(classifications.claim_metadata_conflict > 0);
assert_eq!(
store
.reconciliation_presence(corrupted.artifact_ref())
.unwrap(),
ReconciliationPresence::Unsafe
);
assert_eq!(classifications.registration_safety, 0);
assert_eq!(classifications.registration_retryable, 0);
}
#[tokio::test]
async fn expired_claim_cursor_prevents_unsafe_prefix_starvation_across_ticks() {
let database_url =
crank_test_support::postgres_schema_url("admin_reconciliation_starvation").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
let root = TestRoot::new();
let store = ArtifactStore::open(&root.0).unwrap();
let now = OffsetDateTime::now_utc();
for index in 0..=RECONCILIATION_CANDIDATE_LIMIT {
let artifact = store
.put_registered(format!("unsafe expired claim {index}\n").as_bytes())
.unwrap();
let outcome = registry
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
artifact: &artifact,
token: ArtifactClaimToken::generate(),
claimed_at: now - time::Duration::minutes(20),
lease_expires_at: now - time::Duration::minutes(15),
detached_grace: time::Duration::hours(24),
})
.await
.unwrap();
assert!(matches!(outcome, ArtifactClaimOutcome::Claimed(_)));
sqlx::query(
"update artifact_blobs
set claim_expires_at = clock_timestamp() - interval '10 minutes'
where digest = $1",
)
.bind(artifact.artifact_ref().digest_hex())
.execute(registry.pool())
.await
.unwrap();
fs::set_permissions(
artifact_path(&root.0, &artifact),
fs::Permissions::from_mode(0o600),
)
.unwrap();
}
let absent = store
.put_registered(b"absent after unsafe prefix\n")
.unwrap();
let outcome = registry
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
artifact: &absent,
token: ArtifactClaimToken::generate(),
claimed_at: now - time::Duration::minutes(15),
lease_expires_at: now - time::Duration::minutes(10),
detached_grace: time::Duration::hours(24),
})
.await
.unwrap();
assert!(matches!(outcome, ArtifactClaimOutcome::Claimed(_)));
sqlx::query(
"update artifact_blobs
set claim_expires_at = clock_timestamp() - interval '5 minutes'
where digest = $1",
)
.bind(absent.artifact_ref().digest_hex())
.execute(registry.pool())
.await
.unwrap();
remove_registered_artifact(&store, &absent);
let backend = PostgresReconciliationBackend::new(registry.clone(), Arc::new(store));
let mut coordinator = ReconciliationCoordinator::new(backend);
let first = coordinator.tick().await.unwrap();
assert_eq!(first.candidates, RECONCILIATION_CANDIDATE_LIMIT);
assert_eq!(first.mutation_attempts, 0);
assert_eq!(
first.classifications.recovery_safety,
RECONCILIATION_CANDIDATE_LIMIT
);
let first_lifecycle: String =
sqlx::query_scalar("select storage_lifecycle from artifact_blobs where digest = $1")
.bind(absent.artifact_ref().digest_hex())
.fetch_one(registry.pool())
.await
.unwrap();
assert_eq!(first_lifecycle, "available");
let second = coordinator.tick().await.unwrap();
assert!(second.classifications.recovery_safety > 0);
assert!(second.mutation_attempts > 0);
let second_lifecycle: String =
sqlx::query_scalar("select storage_lifecycle from artifact_blobs where digest = $1")
.bind(absent.artifact_ref().digest_hex())
.fetch_one(registry.pool())
.await
.unwrap();
assert_eq!(second_lifecycle, "unavailable");
}
+109 -126
View File
@@ -1,165 +1,148 @@
use axum::{ use axum::{
extract::Request, extract::{MatchedPath, Request},
http::{HeaderMap, HeaderName, HeaderValue}, http::{HeaderName, HeaderValue},
middleware::Next, middleware::Next,
response::Response, response::Response,
}; };
use tracing::info; use crank_core::{CorrelationContext, RequestId, TraceContext};
use uuid::Uuid; use crank_metrics::ExemplarTraceId;
use crank_observability::{set_remote_trace_parent, with_request_correlation};
use tracing::{Instrument, info, info_span};
pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id"); pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
const MAX_REQUEST_ID_LEN: usize = 128; pub const TRACE_ID_HEADER: HeaderName = HeaderName::from_static("x-trace-id");
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RequestContext { pub struct RequestContext {
pub request_id: String, pub correlation: CorrelationContext,
}
impl RequestContext {
pub fn request_id(&self) -> &str {
self.correlation.request_id().as_str()
}
pub fn trace_id(&self) -> &str {
self.correlation.trace_id().as_str()
}
} }
pub async fn apply_request_context(mut request: Request, next: Next) -> Response { pub async fn apply_request_context(mut request: Request, next: Next) -> Response {
let context = RequestContext { let (request_id, remote_parent) = resolve_correlation(request.headers());
request_id: resolve_request_id(request.headers()),
};
let method = request.method().clone(); let method = request.method().clone();
let path = request.uri().path().to_owned(); let route = request
.extensions()
.get::<MatchedPath>()
.map_or("unmatched", MatchedPath::as_str)
.to_owned();
let span = info_span!(
target: "crank::trace",
"http.request",
request_id = %request_id,
trace_id = tracing::field::Empty,
);
if let Some(remote_parent) = remote_parent.as_ref() {
set_canonical_parent(&span, remote_parent);
}
let trace_context = crank_trace::trace_context_for_span(&span).unwrap_or_else(|| {
remote_parent
.as_ref()
.map_or_else(TraceContext::generate, TraceContext::continue_local)
});
span.record("trace_id", trace_context.trace_id().as_str());
let context = RequestContext {
correlation: CorrelationContext::new(request_id, trace_context),
};
request.extensions_mut().insert(context.clone()); request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await; with_request_correlation(
context.correlation.request_id().to_string(),
context.correlation.trace_id().to_string(),
async move {
let mut response = next.run(request).instrument(span).await;
info!( info!(
request_id = %context.request_id, name: "admin.request.completed",
request_id = %context.correlation.request_id(),
trace_id = %context.correlation.trace_id(),
method = %method, method = %method,
path, route,
status = response.status().as_u16(), status = response.status().as_u16(),
"admin request completed" "admin request completed"
); );
if let Ok(value) = HeaderValue::from_str(&context.request_id) { if let Ok(value) = HeaderValue::from_str(context.correlation.request_id().as_str()) {
response.headers_mut().insert(REQUEST_ID_HEADER, value); response.headers_mut().insert(REQUEST_ID_HEADER, value);
} }
if let Ok(value) = HeaderValue::from_str(context.correlation.trace_id().as_str()) {
response.headers_mut().insert(TRACE_ID_HEADER, value);
}
if context.correlation.trace_context().is_sampled()
&& let Some(exemplar) =
ExemplarTraceId::parse(context.correlation.trace_id().as_str())
{
response.extensions_mut().insert(exemplar);
}
response response
},
)
.await
} }
fn resolve_request_id(headers: &HeaderMap) -> String { fn resolve_correlation(headers: &axum::http::HeaderMap) -> (RequestId, Option<TraceContext>) {
headers let _tracestate_accepted = one_auxiliary_header_within_budget(
.get(&REQUEST_ID_HEADER) headers,
.and_then(|value| value.to_str().ok()) "tracestate",
.map(str::trim) TraceContext::tracestate_within_budget,
.filter(|value| is_valid_request_id(value)) );
.map(ToOwned::to_owned) let _baggage_accepted =
.unwrap_or_else(|| Uuid::now_v7().to_string()) one_auxiliary_header_within_budget(headers, "baggage", TraceContext::baggage_within_budget);
let mut request_ids = headers.get_all(REQUEST_ID_HEADER).iter();
let request_id = request_ids.next().and_then(|value| value.to_str().ok());
let request_id = if request_ids.next().is_some() {
RequestId::generate()
} else {
RequestId::resolve(request_id)
};
let mut traceparents = headers.get_all("traceparent").iter();
let traceparent = traceparents.next().and_then(|value| value.to_str().ok());
let remote_parent = if traceparents.next().is_some() {
None
} else {
traceparent.and_then(|value| TraceContext::parse(value).ok())
};
(request_id, remote_parent)
} }
fn is_valid_request_id(value: &str) -> bool { fn one_auxiliary_header_within_budget(
!value.is_empty() headers: &axum::http::HeaderMap,
&& value.len() <= MAX_REQUEST_ID_LEN name: &'static str,
&& value validate: fn(&str) -> bool,
.bytes() ) -> bool {
.all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';') let mut values = headers.get_all(name).iter();
let value = values.next().and_then(|value| value.to_str().ok());
values.next().is_none() && value.is_some_and(validate)
}
fn set_canonical_parent(span: &tracing::Span, context: &TraceContext) {
let mut headers = axum::http::HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(context.traceparent()) {
headers.insert("traceparent", value);
set_remote_trace_parent(span, &headers);
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::io;
use std::sync::{Arc, Mutex};
use axum::{Router, routing::get};
use reqwest::Client;
use tokio::net::TcpListener;
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
use super::{REQUEST_ID_HEADER, apply_request_context, is_valid_request_id};
#[test] #[test]
fn accepts_visible_ascii_request_ids() { fn accepts_visible_ascii_request_ids() {
assert!(is_valid_request_id("req_test_123")); assert!(crank_core::RequestId::is_valid("req_test_123"));
assert!(is_valid_request_id("trace-123/abc")); assert!(crank_core::RequestId::is_valid("trace-123/abc"));
} }
#[test] #[test]
fn rejects_empty_or_control_request_ids() { fn rejects_empty_or_control_request_ids() {
assert!(!is_valid_request_id("")); assert!(!crank_core::RequestId::is_valid(""));
assert!(!is_valid_request_id("bad value")); assert!(!crank_core::RequestId::is_valid("bad value"));
assert!(!is_valid_request_id("bad\nvalue")); assert!(!crank_core::RequestId::is_valid("bad\nvalue"));
}
#[derive(Clone, Default)]
struct SharedLogWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl SharedLogWriter {
fn output(&self) -> String {
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
}
}
impl<'a> MakeWriter<'a> for SharedLogWriter {
type Writer = SharedLogGuard;
fn make_writer(&'a self) -> Self::Writer {
SharedLogGuard {
buffer: Arc::clone(&self.buffer),
}
}
}
struct SharedLogGuard {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl io::Write for SharedLogGuard {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn logs_request_completion_with_request_id() {
let writer = SharedLogWriter::default();
let subscriber = tracing_subscriber::registry().with(
tracing_subscriber::fmt::layer()
.with_writer(writer.clone())
.without_time()
.with_ansi(false)
.with_target(false)
.compact()
.with_filter(LevelFilter::INFO),
);
let dispatch = tracing::Dispatch::new(subscriber);
let app = Router::new()
.route("/probe", get(|| async { "ok" }))
.layer(axum::middleware::from_fn(apply_request_context));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let _guard = tracing::dispatcher::set_default(&dispatch);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let response = Client::new()
.get(format!("http://{address}/probe"))
.header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123")
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(
response.headers()[REQUEST_ID_HEADER.as_str()]
.to_str()
.unwrap(),
"req_admin_trace_123"
);
let logs = writer.output();
assert!(logs.contains("admin request completed"));
assert!(logs.contains("req_admin_trace_123"));
assert!(logs.contains("GET"));
assert!(logs.contains("/probe"));
assert!(logs.contains("status=200"));
} }
} }
+92 -1
View File
@@ -5,17 +5,108 @@ pub mod auth_profiles;
pub mod capabilities; pub mod capabilities;
pub mod imports; pub mod imports;
pub mod observability; pub mod observability;
pub mod onboarding;
pub mod operations; pub mod operations;
pub mod secrets; pub mod secrets;
pub mod upstreams; pub mod upstreams;
pub mod workspaces; pub mod workspaces;
use axum::Json; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde_json::json; use serde_json::json;
use crate::state::AppState;
pub async fn health() -> Json<serde_json::Value> { pub async fn health() -> Json<serde_json::Value> {
Json(json!({ Json(json!({
"service": "admin-api", "service": "admin-api",
"status": "ok" "status": "ok"
})) }))
} }
pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse {
let checks = state.service.readiness().await;
let postgres = if checks.postgres {
"ready"
} else {
"not_ready"
};
let artifact_storage = if checks.artifact_storage {
"ready"
} else {
"not_ready"
};
if checks.is_ready() {
(
StatusCode::OK,
Json(json!({
"service": "admin-api",
"status": "ready",
"checks": { "postgres": postgres, "artifact_storage": artifact_storage }
})),
)
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"service": "admin-api",
"status": "not_ready",
"checks": { "postgres": postgres, "artifact_storage": artifact_storage }
})),
)
}
}
#[cfg(test)]
mod tests {
use crate::service::ReadinessChecks;
#[test]
fn readiness_checks_identify_a_postgres_failure() {
let checks = ReadinessChecks {
postgres: false,
artifact_storage: true,
};
assert!(!checks.is_ready());
assert_eq!(
if checks.postgres {
"ready"
} else {
"not_ready"
},
"not_ready"
);
assert_eq!(
if checks.artifact_storage {
"ready"
} else {
"not_ready"
},
"ready"
);
}
#[test]
fn readiness_checks_identify_an_artifact_storage_failure() {
let checks = ReadinessChecks {
postgres: true,
artifact_storage: false,
};
assert!(!checks.is_ready());
assert_eq!(
if checks.postgres {
"ready"
} else {
"not_ready"
},
"ready"
);
assert_eq!(
if checks.artifact_storage {
"ready"
} else {
"not_ready"
},
"not_ready"
);
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ pub async fn export_workspace(
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let exported = state let exported = state
.service .service
.export_workspace(&path.workspace_id.as_str().into()) .export_workspace_catalog_snapshot(&path.workspace_id.as_str().into())
.await?; .await?;
Ok(Json(json!(exported))) Ok(Json(json!(exported)))
} }
+105 -6
View File
@@ -1,15 +1,19 @@
use axum::{ use axum::{
Json, Extension, Json,
extract::{Path, State}, extract::{Path, State},
http::{HeaderMap, header},
response::IntoResponse,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::{ use crate::{
auth::AuthenticatedSession,
error::ApiError, error::ApiError,
request_context::RequestContext,
service::{ service::{
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload, AdminAuditContext, AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload,
UpdateAgentPayload, PublishPayload, ToolSearchPreviewPayload, UpdateAgentPayload,
}, },
state::AppState, state::AppState,
}; };
@@ -50,6 +54,18 @@ pub async fn list_agents(
Ok(Json(json!({ "items": items }))) Ok(Json(json!({ "items": items })))
} }
pub async fn preview_tool_search(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<ToolSearchPreviewPayload>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.preview_tool_search(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!({"items": items})))
}
pub async fn create_agent( pub async fn create_agent(
Path(path): Path<WorkspacePath>, Path(path): Path<WorkspacePath>,
State(state): State<AppState>, State(state): State<AppState>,
@@ -65,7 +81,7 @@ pub async fn create_agent(
pub async fn get_agent( pub async fn get_agent(
Path(path): Path<WorkspaceAgentPath>, Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let agent = state let agent = state
.service .service
.get_agent( .get_agent(
@@ -73,20 +89,30 @@ pub async fn get_agent(
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
) )
.await?; .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( pub async fn update_agent(
Path(path): Path<WorkspaceAgentPath>, Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateAgentPayload>, Json(payload): Json<UpdateAgentPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let updated = state let updated = state
.service .service
.update_agent( .update_agent(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
payload, payload,
Some(&expected_state),
) )
.await?; .await?;
Ok(Json(json!(updated))) Ok(Json(json!(updated)))
@@ -95,12 +121,15 @@ pub async fn update_agent(
pub async fn delete_agent( pub async fn delete_agent(
Path(path): Path<WorkspaceAgentPath>, Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let deleted = state let deleted = state
.service .service
.delete_agent( .delete_agent(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
Some(&expected_state),
) )
.await?; .await?;
Ok(Json(json!(deleted))) Ok(Json(json!(deleted)))
@@ -124,30 +153,79 @@ pub async fn get_agent_version(
pub async fn save_agent_bindings( pub async fn save_agent_bindings(
Path(path): Path<WorkspaceAgentPath>, Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<Vec<AgentBindingPayload>>, headers: HeaderMap,
Json(payload): Json<AgentCatalogPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let record = state let record = state
.service .service
.save_agent_bindings( .save_agent_bindings(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
payload, payload,
Some(&expected_state),
) )
.await?; .await?;
Ok(Json(json!(record))) Ok(Json(json!(record)))
} }
async fn require_agent_precondition(
state: &AppState,
path: &WorkspaceAgentPath,
headers: &HeaderMap,
) -> Result<crank_registry::AgentStateExpectation, ApiError> {
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( pub async fn publish_agent(
Path(path): Path<WorkspaceAgentPath>, Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<PublishPayload>, Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let published = state let published = state
.service .service
.publish_agent( .publish_agent(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
payload.version, payload.version,
Some(&expected_state),
) )
.await?; .await?;
Ok(Json(json!(published))) Ok(Json(json!(published)))
@@ -156,12 +234,15 @@ pub async fn publish_agent(
pub async fn unpublish_agent( pub async fn unpublish_agent(
Path(path): Path<WorkspaceAgentPath>, Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let updated = state let updated = state
.service .service
.unpublish_agent( .unpublish_agent(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
Some(&expected_state),
) )
.await?; .await?;
Ok(Json(json!(updated))) Ok(Json(json!(updated)))
@@ -170,12 +251,15 @@ pub async fn unpublish_agent(
pub async fn archive_agent( pub async fn archive_agent(
Path(path): Path<WorkspaceAgentPath>, Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let updated = state let updated = state
.service .service
.archive_agent( .archive_agent(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
Some(&expected_state),
) )
.await?; .await?;
Ok(Json(json!(updated))) Ok(Json(json!(updated)))
@@ -198,14 +282,19 @@ pub async fn list_agent_platform_api_keys(
pub async fn create_agent_platform_api_key( pub async fn create_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPath>, Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>, State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<PlatformApiKeyPayload>, Json(payload): Json<PlatformApiKeyPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let created = state let created = state
.service .service
.create_agent_platform_api_key( .create_agent_platform_api_key(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
payload, payload,
Some(&audit_context),
) )
.await?; .await?;
Ok(Json(json!(created))) Ok(Json(json!(created)))
@@ -214,13 +303,18 @@ pub async fn create_agent_platform_api_key(
pub async fn revoke_agent_platform_api_key( pub async fn revoke_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>, Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>, State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
) -> Result<axum::http::StatusCode, ApiError> { ) -> Result<axum::http::StatusCode, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
state state
.service .service
.revoke_agent_platform_api_key( .revoke_agent_platform_api_key(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
&path.key_id.as_str().into(), &path.key_id.as_str().into(),
Some(&audit_context),
) )
.await?; .await?;
Ok(axum::http::StatusCode::NO_CONTENT) Ok(axum::http::StatusCode::NO_CONTENT)
@@ -229,13 +323,18 @@ pub async fn revoke_agent_platform_api_key(
pub async fn delete_agent_platform_api_key( pub async fn delete_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>, Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>, State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
) -> Result<axum::http::StatusCode, ApiError> { ) -> Result<axum::http::StatusCode, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
state state
.service .service
.delete_agent_platform_api_key( .delete_agent_platform_api_key(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(), &path.agent_id.as_str().into(),
&path.key_id.as_str().into(), &path.key_id.as_str().into(),
Some(&audit_context),
) )
.await?; .await?;
Ok(axum::http::StatusCode::NO_CONTENT) Ok(axum::http::StatusCode::NO_CONTENT)
+54 -3
View File
@@ -5,18 +5,52 @@ use serde_json::json;
use crate::{ use crate::{
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie}, auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
error::ApiError, error::ApiError,
rate_limit::ClientIdentityBucket,
service::{ service::{
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload, ChangePasswordPayload, CompleteBootstrapPayload, LoginPayload,
UpdateCurrentWorkspacePayload, UpdateProfilePayload,
}, },
state::AppState, state::AppState,
}; };
pub async fn bootstrap_status(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
Ok(Json(serde_json::json!(
state.service.bootstrap_status().await?
)))
}
pub async fn complete_bootstrap(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<CompleteBootstrapPayload>,
) -> Result<impl IntoResponse, ApiError> {
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( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
jar: CookieJar, jar: CookieJar,
client_bucket: Option<Extension<ClientIdentityBucket>>,
Json(payload): Json<LoginPayload>, Json(payload): Json<LoginPayload>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
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!( let cookie_value = format!(
"{}.{}", "{}.{}",
session_data.session_id.as_str(), session_data.session_id.as_str(),
@@ -54,6 +88,22 @@ pub async fn get_session(
Ok(Json(serde_json::json!(session))) Ok(Json(serde_json::json!(session)))
} }
pub async fn refresh_session_csrf(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<Json<serde_json::Value>, 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( pub async fn get_profile(
Extension(session): Extension<AuthenticatedSession>, Extension(session): Extension<AuthenticatedSession>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
@@ -73,6 +123,7 @@ pub async fn update_profile(
.service .service
.update_profile( .update_profile(
&session.user.id, &session.user.id,
&session.session_id,
session.current_workspace_id.as_ref(), session.current_workspace_id.as_ref(),
payload, payload,
) )
@@ -103,7 +154,7 @@ pub async fn change_password(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
state state
.service .service
.change_password(&session.user.id, payload) .change_password(&session.user.id, &session.session_id, payload)
.await?; .await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+17 -3
View File
@@ -1,11 +1,17 @@
use axum::{ use axum::{
Json, Extension, Json,
extract::{Path, State}, extract::{Path, State},
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; 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)] #[derive(Deserialize)]
pub struct WorkspacePath { pub struct WorkspacePath {
@@ -32,11 +38,19 @@ pub async fn list_auth_profiles(
pub async fn create_auth_profile( pub async fn create_auth_profile(
Path(path): Path<WorkspacePath>, Path(path): Path<WorkspacePath>,
State(state): State<AppState>, State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<AuthProfilePayload>, Json(payload): Json<AuthProfilePayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let profile = state let profile = state
.service .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?; .await?;
Ok(Json(json!(profile))) Ok(Json(json!(profile)))
} }
+175 -5
View File
@@ -1,13 +1,16 @@
use axum::{ use axum::{
Json, Json,
extract::{Path, State}, extract::{Multipart, Path, State, multipart::MultipartRejection},
http::HeaderMap,
response::IntoResponse,
}; };
use crank_artifacts::MAX_ARTIFACT_BYTES;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::{ use crate::{
error::ApiError, error::ApiError,
service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload}, service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale},
state::AppState, state::AppState,
}; };
@@ -25,27 +28,194 @@ pub struct WorkspaceImportPath {
pub async fn preview_openapi_import( pub async fn preview_openapi_import(
Path(path): Path<WorkspacePath>, Path(path): Path<WorkspacePath>,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<OpenApiImportPreviewPayload>, headers: HeaderMap,
multipart: Result<Multipart, MultipartRejection>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let locale = openapi_upload_locale(&headers);
let upload = parse_openapi_upload(
multipart.map_err(|rejection| multipart_rejection(locale, rejection))?,
locale,
)
.await?;
let preview = state let preview = state
.service .service
.preview_openapi_import(&path.workspace_id.as_str().into(), payload) .preview_openapi_import(&path.workspace_id.as_str().into(), upload)
.await?; .await?;
Ok(Json(json!(preview))) Ok(Json(json!(preview)))
} }
fn multipart_rejection(locale: OpenApiUploadLocale, rejection: MultipartRejection) -> ApiError {
if rejection.into_response().status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE {
ApiError::openapi_upload(locale, "file_too_large")
} else {
ApiError::openapi_upload(locale, "malformed_multipart")
}
}
async fn parse_openapi_upload(
mut multipart: Multipart,
locale: OpenApiUploadLocale,
) -> Result<OpenApiUpload, ApiError> {
let mut upload = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))?
{
if upload.is_some() || field.name() != Some("file") {
return Err(ApiError::openapi_upload(locale, "malformed_multipart"));
}
let filename = field
.file_name()
.ok_or_else(|| ApiError::openapi_upload(locale, "invalid_filename"))?;
let mime_type = field
.content_type()
// Axum exposes the raw header value here. Persist only the MIME
// essence so parameters and case cannot make route and service
// validation disagree.
.map(|mime| {
mime.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
})
.ok_or_else(|| ApiError::openapi_upload(locale, "invalid_media_type"))?;
if !valid_upload_type(filename, &mime_type) {
return Err(ApiError::openapi_upload(locale, "invalid_media_type"));
}
let mut bytes = Vec::with_capacity(8 * 1024);
let mut field = field;
while let Some(chunk) = field
.chunk()
.await
.map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))?
{
if bytes.len().saturating_add(chunk.len()) > MAX_ARTIFACT_BYTES {
return Err(ApiError::openapi_upload(locale, "file_too_large"));
}
bytes.extend_from_slice(&chunk);
}
if bytes.is_empty() {
return Err(ApiError::openapi_upload(locale, "empty_file"));
}
if std::str::from_utf8(&bytes).is_err() {
return Err(ApiError::openapi_upload(locale, "invalid_utf8"));
}
upload = Some(OpenApiUpload {
bytes,
mime_type,
locale,
});
}
upload.ok_or_else(|| ApiError::openapi_upload(locale, "missing_file"))
}
fn valid_upload_type(filename: &str, mime_type: &str) -> bool {
let filename = filename.to_ascii_lowercase();
let mime_type = mime_type.to_ascii_lowercase();
match filename.rsplit_once('.') {
Some((_, "yaml" | "yml")) => matches!(
mime_type.as_str(),
"application/yaml"
| "application/x-yaml"
| "text/yaml"
| "text/x-yaml"
| "application/octet-stream"
),
Some((_, "json")) => matches!(
mime_type.as_str(),
"application/json" | "application/openapi+json" | "application/octet-stream"
),
_ => false,
}
}
fn openapi_upload_locale(headers: &HeaderMap) -> OpenApiUploadLocale {
let Some(value) = headers
.get("accept-language")
.and_then(|value| value.to_str().ok())
else {
return OpenApiUploadLocale::En;
};
// RFC 9110: highest q wins; ties preserve the header's order. Only the
// locales served by this endpoint participate in negotiation.
let mut preferred = (0_u16, usize::MAX, OpenApiUploadLocale::En);
for (index, range) in value.split(',').enumerate() {
let mut parts = range.split(';');
let language = parts.next().unwrap_or_default().trim();
let locale = if language.eq_ignore_ascii_case("ru")
|| language.to_ascii_lowercase().starts_with("ru-")
{
Some(OpenApiUploadLocale::Ru)
} else if language.eq_ignore_ascii_case("en")
|| language.to_ascii_lowercase().starts_with("en-")
|| language == "*"
{
Some(OpenApiUploadLocale::En)
} else {
None
};
let Some(locale) = locale else { continue };
let quality = match parts
.filter_map(|parameter| {
let (name, value) = parameter.trim().split_once('=')?;
name.eq_ignore_ascii_case("q").then_some(value.trim())
})
.next()
{
None => Some(1_000_u16),
Some(value) => value
.parse::<f32>()
.ok()
.filter(|quality| (0.0..=1.0).contains(quality) && *quality > 0.0)
.map(|quality| (quality * 1_000.0).round() as u16),
};
let Some(quality) = quality else { continue };
if quality > preferred.0 || (quality == preferred.0 && index < preferred.1) {
preferred = (quality, index, locale);
}
}
preferred.2
}
pub async fn create_openapi_import( pub async fn create_openapi_import(
Path(path): Path<WorkspaceImportPath>, Path(path): Path<WorkspaceImportPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<OpenApiImportCreatePayload>, Json(payload): Json<OpenApiImportCreatePayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let imported = state let imported = state
.service .service
.create_openapi_import( .create_openapi_import_with_locale(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.job_id.as_str().into(), &path.job_id.as_str().into(),
payload, payload,
openapi_upload_locale(&headers),
) )
.await?; .await?;
Ok(Json(json!(imported))) Ok(Json(json!(imported)))
} }
#[cfg(test)]
mod tests {
use axum::http::{HeaderMap, HeaderValue};
use super::*;
fn locale(value: &str) -> OpenApiUploadLocale {
let mut headers = HeaderMap::new();
headers.insert("accept-language", HeaderValue::from_str(value).unwrap());
openapi_upload_locale(&headers)
}
#[test]
fn accept_language_honours_quality_zero_and_header_order() {
assert_eq!(locale("ru;q=0, en;q=0.5"), OpenApiUploadLocale::En);
assert_eq!(locale("en;q=0.5, ru;q=0.5"), OpenApiUploadLocale::En);
assert_eq!(locale("ru-RU;q=0.9, en;q=1"), OpenApiUploadLocale::En);
assert_eq!(locale("ru, en;q=0.5"), OpenApiUploadLocale::Ru);
assert_eq!(locale("ru;q=bad, en;q=0.5"), OpenApiUploadLocale::En);
}
}
+81 -2
View File
@@ -1,13 +1,18 @@
use axum::{ use axum::{
Json, Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::{
HeaderValue, StatusCode,
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
},
response::{IntoResponse, Response},
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::{ use crate::{
error::ApiError, error::ApiError,
routes::access::WorkspacePath, routes::access::WorkspacePath,
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery}, service::{ApprovalDecisionPayload, ApprovalsQuery, LogsQuery, UsageRequestQuery},
state::AppState, state::AppState,
}; };
@@ -44,7 +49,49 @@ pub async fn list_logs(
.service .service
.list_logs(&path.workspace_id.as_str().into(), query) .list_logs(&path.workspace_id.as_str().into(), query)
.await?; .await?;
Ok(Json(json!({ "items": items }))) Ok(Json(json!(items)))
}
pub async fn export_logs_csv(
Path(path): Path<WorkspacePath>,
Query(query): Query<LogsQuery>,
State(state): State<AppState>,
) -> Result<Response, ApiError> {
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<WorkspacePath>,
Query(query): Query<UsageRequestQuery>,
State(state): State<AppState>,
) -> Result<Response, ApiError> {
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( pub async fn get_log(
@@ -87,6 +134,38 @@ pub async fn get_approval(
Ok(Json(json!(item))) Ok(Json(json!(item)))
} }
pub async fn approve_approval(
Path(path): Path<WorkspaceApprovalPath>,
State(state): State<AppState>,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Result<Json<Value>, 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<WorkspaceApprovalPath>,
State(state): State<AppState>,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Result<Json<Value>, 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( pub async fn get_usage(
Path(path): Path<WorkspacePath>, Path(path): Path<WorkspacePath>,
Query(query): Query<UsageRequestQuery>, Query(query): Query<UsageRequestQuery>,
+59
View File
@@ -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<WorkspaceOnboardingPath>,
State(state): State<AppState>,
Json(payload): Json<ResetOnboardingSelectionPayload>,
) -> Result<Json<ResetOnboardingSelectionResponse>, 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<WorkspaceOnboardingPath>,
State(state): State<AppState>,
) -> Result<Json<OnboardingResponse>, ApiError> {
Ok(Json(
state
.service
.get_onboarding(&path.workspace_id.as_str().into())
.await?,
))
}
pub async fn record_onboarding_event(
Path(path): Path<WorkspaceOnboardingPath>,
State(state): State<AppState>,
Json(payload): Json<OnboardingEventPayload>,
) -> Result<Json<OnboardingEventResponse>, ApiError> {
Ok(Json(
state
.service
.record_onboarding_event(&path.workspace_id.as_str().into(), payload)
.await?,
))
}
+103 -8
View File
@@ -1,9 +1,10 @@
use axum::{ use axum::{
Json, Json,
extract::{Extension, Path, Query, State}, extract::{Extension, Path, Query, State, rejection::StringRejection},
http::{HeaderMap, StatusCode, header}, http::{HeaderMap, StatusCode, header},
response::IntoResponse, response::IntoResponse,
}; };
use crank_registry::OperationStateExpectation;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -79,7 +80,7 @@ pub async fn analyze_operation_quality(
pub async fn get_operation( pub async fn get_operation(
Path(path): Path<WorkspaceOperationPath>, Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let operation = state let operation = state
.service .service
.get_operation( .get_operation(
@@ -87,20 +88,30 @@ pub async fn get_operation(
&path.operation_id.as_str().into(), &path.operation_id.as_str().into(),
) )
.await?; .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( pub async fn update_operation(
Path(path): Path<WorkspaceOperationPath>, Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateOperationPayload>, Json(payload): Json<UpdateOperationPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let result = state let result = state
.service .service
.update_operation( .update_operation(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(), &path.operation_id.as_str().into(),
payload, payload,
&expected,
) )
.await?; .await?;
Ok(Json(json!(result))) Ok(Json(json!(result)))
@@ -109,12 +120,15 @@ pub async fn update_operation(
pub async fn delete_operation( pub async fn delete_operation(
Path(path): Path<WorkspaceOperationPath>, Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let result = state let result = state
.service .service
.delete_operation( .delete_operation(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(), &path.operation_id.as_str().into(),
&expected,
) )
.await?; .await?;
Ok(Json(json!(result))) Ok(Json(json!(result)))
@@ -123,7 +137,7 @@ pub async fn delete_operation(
pub async fn get_operation_version( pub async fn get_operation_version(
Path(path): Path<WorkspaceOperationVersionPath>, Path(path): Path<WorkspaceOperationVersionPath>,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let version = state let version = state
.service .service
.get_operation_version( .get_operation_version(
@@ -132,20 +146,30 @@ pub async fn get_operation_version(
path.version, path.version,
) )
.await?; .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( pub async fn create_version(
Path(path): Path<WorkspaceOperationPath>, Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<NewVersionPayload>, Json(payload): Json<NewVersionPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let created = state let created = state
.service .service
.create_version( .create_version(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(), &path.operation_id.as_str().into(),
payload, payload,
&expected,
) )
.await?; .await?;
Ok(Json(json!(created))) Ok(Json(json!(created)))
@@ -154,14 +178,17 @@ pub async fn create_version(
pub async fn publish_operation( pub async fn publish_operation(
Path(path): Path<WorkspaceOperationPath>, Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<PublishPayload>, Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let published = state let published = state
.service .service
.publish_operation( .publish_operation(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(), &path.operation_id.as_str().into(),
payload.version, payload.version,
&expected,
) )
.await?; .await?;
Ok(Json(json!(published))) Ok(Json(json!(published)))
@@ -170,17 +197,63 @@ pub async fn publish_operation(
pub async fn archive_operation( pub async fn archive_operation(
Path(path): Path<WorkspaceOperationPath>, Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let archived = state let archived = state
.service .service
.archive_operation( .archive_operation(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(), &path.operation_id.as_str().into(),
&expected,
) )
.await?; .await?;
Ok(Json(json!(archived))) Ok(Json(json!(archived)))
} }
async fn require_operation_precondition(
state: &AppState,
path: &WorkspaceOperationPath,
headers: &HeaderMap,
) -> Result<OperationStateExpectation, ApiError> {
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( pub async fn run_test(
Path(path): Path<WorkspaceOperationPath>, Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>, State(state): State<AppState>,
@@ -193,7 +266,7 @@ pub async fn run_test(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(), &path.operation_id.as_str().into(),
payload, payload,
&request_context.request_id, &request_context.correlation,
) )
.await?; .await?;
Ok(Json(json!(result))) Ok(Json(json!(result)))
@@ -285,11 +358,33 @@ pub async fn import_operation(
Path(path): Path<WorkspacePath>, Path(path): Path<WorkspacePath>,
Query(query): Query<ImportQuery>, Query(query): Query<ImportQuery>,
State(state): State<AppState>, State(state): State<AppState>,
body: String, headers: HeaderMap,
body: Result<String, StringRejection>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, 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 let imported = state
.service .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?; .await?;
Ok(Json(json!(imported))) Ok(Json(json!(imported)))
} }
+15 -1
View File
@@ -8,7 +8,8 @@ use serde_json::{Value, json};
use crate::{ use crate::{
auth::AuthenticatedSession, auth::AuthenticatedSession,
error::ApiError, error::ApiError,
service::{RotateSecretPayload, SecretPayload}, request_context::RequestContext,
service::{AdminAuditContext, RotateSecretPayload, SecretPayload},
state::AppState, state::AppState,
}; };
@@ -38,14 +39,18 @@ pub async fn create_secret(
Path(path): Path<WorkspacePath>, Path(path): Path<WorkspacePath>,
State(state): State<AppState>, State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>, Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<SecretPayload>, Json(payload): Json<SecretPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let secret = state let secret = state
.service .service
.create_secret( .create_secret(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
Some(&session.user.id), Some(&session.user.id),
payload, payload,
Some(&audit_context),
) )
.await?; .await?;
Ok(Json(json!(secret))) Ok(Json(json!(secret)))
@@ -69,8 +74,11 @@ pub async fn rotate_secret(
Path(path): Path<WorkspaceSecretPath>, Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>, State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>, Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<RotateSecretPayload>, Json(payload): Json<RotateSecretPayload>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let secret = state let secret = state
.service .service
.rotate_secret( .rotate_secret(
@@ -78,6 +86,7 @@ pub async fn rotate_secret(
&path.secret_id.as_str().into(), &path.secret_id.as_str().into(),
Some(&session.user.id), Some(&session.user.id),
payload, payload,
Some(&audit_context),
) )
.await?; .await?;
Ok(Json(json!(secret))) Ok(Json(json!(secret)))
@@ -86,12 +95,17 @@ pub async fn rotate_secret(
pub async fn delete_secret( pub async fn delete_secret(
Path(path): Path<WorkspaceSecretPath>, Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>, State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
state state
.service .service
.delete_secret( .delete_secret(
&path.workspace_id.as_str().into(), &path.workspace_id.as_str().into(),
&path.secret_id.as_str().into(), &path.secret_id.as_str().into(),
Some(&audit_context),
) )
.await?; .await?;
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
File diff suppressed because it is too large Load Diff
+335 -83
View File
@@ -1,27 +1,147 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crank_core::{ use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, UsagePeriod, Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, SearchableTool,
WorkspaceId, ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
}; };
use crank_registry::{ use crank_registry::{
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest, AgentStateExpectation, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
SaveAgentBindingsRequest, UsageBucket, UsageQuery, PublishAgentRequest, SaveAgentCatalogConfigRequest, UpdateAgentSummaryRequest, UsageBucket,
UsageQuery,
}; };
use serde_json::json; use serde_json::json;
use time::OffsetDateTime; use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, instrument}; use tracing::{info, instrument};
use crate::{ use crate::{
error::ApiError, error::ApiError,
service::{ service::{
AdminService, AgentBindingPayload, AgentMutationResult, AgentPayload, AgentSummaryView, AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
CreatedAgentResponse, PublishAgentResponse, UpdateAgentPayload, agent_mcp_endpoint, CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
format_timestamp, map_agent_summary_view, new_prefixed_id, today_start_utc, agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id, now_string,
today_start_utc,
}, },
}; };
impl AdminService { 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<AgentStateExpectation, ApiError> {
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,
workspace_id: &WorkspaceId,
payload: ToolSearchPreviewPayload,
) -> Result<Vec<crank_core::ToolSearchMatch>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if payload.tool_selection_policy.mode != ToolAccessMode::Search {
return Err(ApiError::validation_with_context(
"tool search preview requires search mode",
json!({"field": "tool_selection_policy.mode"}),
));
}
let bindings = payload
.bindings
.iter()
.map(|binding| AgentOperationBinding {
operation_id: OperationId::new(binding.operation_id.clone()),
operation_version: binding.operation_version,
tool_name: binding.tool_name.clone(),
tool_title: binding.tool_title.clone(),
tool_description_override: binding.tool_description_override.clone(),
enabled: binding.enabled,
})
.collect::<Vec<_>>();
validate_tool_selection_policy(&payload.tool_selection_policy, &bindings)?;
let mut tools = Vec::new();
for binding in bindings.iter().filter(|binding| binding.enabled) {
let version = self
.registry
.get_operation_version(
workspace_id,
&binding.operation_id,
binding.operation_version,
)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("operation {} was not found", binding.operation_id.as_str()),
json!({"operation_id": binding.operation_id.as_str()}),
)
})?;
let groups = payload
.tool_selection_policy
.groups
.iter()
.filter(|group| {
group
.tool_names
.iter()
.any(|name| name == &binding.tool_name)
})
.collect::<Vec<_>>();
tools.push(SearchableTool {
name: binding.tool_name.clone(),
title: binding.tool_title.clone(),
description: binding
.tool_description_override
.clone()
.unwrap_or(version.snapshot.tool_description.description),
input_schema: serde_json::Value::Null,
group_ids: groups.iter().map(|group| group.id.clone()).collect(),
group_context: groups
.iter()
.map(|group| format!("{} {}", group.name, group.description))
.collect::<Vec<_>>()
.join(" "),
});
}
let max_results = payload.tool_selection_policy.search.max_results;
Ok(search_tool_catalog(
&tools,
&payload.query,
&payload.group_ids,
max_results,
))
}
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn list_agents( pub async fn list_agents(
&self, &self,
@@ -30,13 +150,16 @@ impl AdminService {
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
let workspace = self.get_workspace(workspace_id).await?; let workspace = self.get_workspace(workspace_id).await?;
let summaries = self.registry.list_agents(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 let usage = self
.registry .registry
.list_usage_by_agent(UsageQuery { .list_usage_by_agent(UsageQuery {
workspace_id, workspace_id,
period: UsagePeriod::Last24Hours, period: UsagePeriod::Last24Hours,
source: None, source: None,
created_after: &today_start_utc()?, created_after: &usage_start,
created_before: &usage_end,
bucket: UsageBucket::Hour, bucket: UsageBucket::Hour,
}) })
.await?; .await?;
@@ -69,6 +192,7 @@ impl AdminService {
items.push(AgentSummaryView { items.push(AgentSummaryView {
operation_count: operation_ids.len(), operation_count: operation_ids.len(),
operation_ids, operation_ids,
tool_selection_policy: version.snapshot.tool_selection_policy,
key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0), key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0),
calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0), calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
mcp_endpoint: agent_mcp_endpoint( mcp_endpoint: agent_mcp_endpoint(
@@ -108,6 +232,8 @@ impl AdminService {
.iter() .iter()
.map(|binding| binding.operation_id.as_str().to_owned()) .map(|binding| binding.operation_id.as_str().to_owned())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let usage_start = today_start_utc()?;
let usage_end = now_string()?;
let usage = self let usage = self
.registry .registry
.get_usage_for_agent( .get_usage_for_agent(
@@ -115,7 +241,8 @@ impl AdminService {
workspace_id, workspace_id,
period: UsagePeriod::Last24Hours, period: UsagePeriod::Last24Hours,
source: None, source: None,
created_after: &today_start_utc()?, created_after: &usage_start,
created_before: &usage_end,
bucket: UsageBucket::Hour, bucket: UsageBucket::Hour,
}, },
agent_id, agent_id,
@@ -130,6 +257,7 @@ impl AdminService {
Ok(AgentSummaryView { Ok(AgentSummaryView {
operation_count: operation_ids.len(), operation_count: operation_ids.len(),
operation_ids, operation_ids,
tool_selection_policy: version.snapshot.tool_selection_policy,
key_count, key_count,
calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0), calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0),
mcp_endpoint: agent_mcp_endpoint( mcp_endpoint: agent_mcp_endpoint(
@@ -214,7 +342,12 @@ impl AdminService {
bindings: &[], bindings: &[],
}) })
.await?; .await?;
info!(agent_id = %agent_id.as_str(), version = 1, "agent created"); info!(
name: "admin.agent.created",
agent_id = %agent_id.as_str(),
version = 1,
"agent created"
);
Ok(CreatedAgentResponse { Ok(CreatedAgentResponse {
agent_id: agent_id.as_str().to_owned(), agent_id: agent_id.as_str().to_owned(),
@@ -230,6 +363,7 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
payload: UpdateAgentPayload, payload: UpdateAgentPayload,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> { ) -> Result<AgentMutationResult, ApiError> {
let existing = self let existing = self
.registry .registry
@@ -241,6 +375,20 @@ impl AdminService {
json!({ "agent_id": agent_id.as_str() }), 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 if payload.slug != existing.slug
&& self && self
@@ -259,10 +407,13 @@ impl AdminService {
.update_agent_summary( .update_agent_summary(
workspace_id, workspace_id,
agent_id, agent_id,
&payload.slug, UpdateAgentSummaryRequest {
&payload.display_name, slug: &payload.slug,
&payload.description, display_name: &payload.display_name,
&updated_at, description: &payload.description,
updated_at: &updated_at,
expected_state,
},
) )
.await?; .await?;
@@ -278,6 +429,7 @@ impl AdminService {
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> { ) -> Result<AgentMutationResult, ApiError> {
let existing = self let existing = self
.registry .registry
@@ -289,8 +441,20 @@ impl AdminService {
json!({ "agent_id": agent_id.as_str() }), 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 { Ok(AgentMutationResult {
agent_id: agent_id.as_str().to_owned(), agent_id: agent_id.as_str().to_owned(),
@@ -304,9 +468,13 @@ impl AdminService {
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
payload: Vec<AgentBindingPayload>, payload: AgentCatalogPayload,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentVersionRecord, ApiError> { ) -> Result<AgentVersionRecord, ApiError> {
let agent = self.get_agent(workspace_id, agent_id).await?; let current_version = self
.ensure_editable_agent_version(workspace_id, agent_id)
.await?;
let (payload, requested_policy) = payload.into_parts();
let bindings = payload let bindings = payload
.into_iter() .into_iter()
.map(|binding| AgentOperationBinding { .map(|binding| AgentOperationBinding {
@@ -318,23 +486,66 @@ impl AdminService {
enabled: binding.enabled, enabled: binding.enabled,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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)?;
self.registry self.registry
.save_agent_bindings(SaveAgentBindingsRequest { .save_agent_catalog_config(SaveAgentCatalogConfigRequest {
workspace_id, workspace_id,
agent_id, agent_id,
agent_version: agent.current_draft_version, agent_version: current_version.version,
bindings: &bindings, bindings: &bindings,
tool_selection_policy: &tool_selection_policy,
expected_state,
}) })
.await?; .await?;
info!( info!(
name: "admin.agent.bindings_saved",
agent_id = %agent_id.as_str(), agent_id = %agent_id.as_str(),
version = agent.current_draft_version, version = current_version.version,
binding_count = bindings.len(), binding_count = bindings.len(),
"agent bindings saved" "agent bindings saved"
); );
self.get_agent_version(workspace_id, agent_id, agent.current_draft_version) self.get_agent_version(workspace_id, agent_id, current_version.version)
.await
}
async fn ensure_editable_agent_version(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<AgentVersionRecord, ApiError> {
let agent = self.get_agent(workspace_id, agent_id).await?;
let current = self
.get_agent_version(workspace_id, agent_id, agent.current_draft_version)
.await?;
if agent.latest_published_version != Some(agent.current_draft_version) {
return Ok(current);
}
let now = OffsetDateTime::now_utc();
let draft = AgentVersion {
agent_id: agent_id.clone(),
version: current.version + 1,
status: AgentStatus::Draft,
instructions: current.snapshot.instructions.clone(),
tool_selection_policy: current.snapshot.tool_selection_policy.clone(),
created_at: now,
};
self.registry
.create_agent_draft_version(CreateAgentDraftVersionRequest {
workspace_id,
agent_id,
version: &draft,
bindings: &current.bindings,
updated_at: &now,
})
.await?;
self.get_agent_version(workspace_id, agent_id, draft.version)
.await .await
} }
@@ -344,15 +555,23 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
version: u32, version: u32,
expected_state: Option<&AgentStateExpectation>,
) -> Result<PublishAgentResponse, ApiError> { ) -> Result<PublishAgentResponse, ApiError> {
let agent_version = self let agent_version = self
.get_agent_version(workspace_id, agent_id, version) .get_agent_version(workspace_id, agent_id, version)
.await?; .await?;
let published_bindings = self validate_tool_selection_policy(
.published_agent_bindings(workspace_id, &agent_version.bindings) &agent_version.snapshot.tool_selection_policy,
.await?; &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( return Err(ApiError::conflict_with_context(
"agent cannot be published without published enabled tools", "agent cannot be published without published enabled tools",
json!({ json!({
@@ -363,36 +582,10 @@ impl AdminService {
)); ));
} }
self.validate_exact_published_agent_bindings(workspace_id, &agent_version.bindings)
.await?;
let published_at = OffsetDateTime::now_utc(); 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 self.registry
.publish_agent(PublishAgentRequest { .publish_agent(PublishAgentRequest {
@@ -401,9 +594,15 @@ impl AdminService {
version, version,
published_at: &published_at, published_at: &published_at,
published_by: None, published_by: None,
expected_state,
}) })
.await?; .await?;
info!(agent_id = %agent_id.as_str(), version, "agent published"); info!(
name: "admin.agent.published",
agent_id = %agent_id.as_str(),
version,
"agent published"
);
Ok(PublishAgentResponse { Ok(PublishAgentResponse {
agent_id: agent_id.as_str().to_owned(), agent_id: agent_id.as_str().to_owned(),
@@ -413,41 +612,65 @@ impl AdminService {
}) })
} }
async fn published_agent_bindings( async fn validate_exact_published_agent_bindings(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
bindings: &[AgentOperationBinding], bindings: &[AgentOperationBinding],
) -> Result<Vec<AgentOperationBinding>, ApiError> { ) -> Result<(), ApiError> {
let mut published = Vec::new();
for binding in bindings { for binding in bindings {
if !binding.enabled {
continue;
}
let Some(summary) = self let Some(summary) = self
.registry .registry
.get_operation_summary(workspace_id, &binding.operation_id) .get_operation_summary(workspace_id, &binding.operation_id)
.await? .await?
else { 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 {
let Some(operation_version) = summary.latest_published_version else { return Err(ApiError::unprocessable_with_context(
continue; "archived operation cannot be added to an Agent binding",
}; json!({
"error_code": "agent_binding_archived_operation",
published.push(AgentOperationBinding { "operation_id": binding.operation_id.as_str()
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,
});
} }
Ok(published) 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
}),
));
};
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(())
} }
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
@@ -455,13 +678,18 @@ impl AdminService {
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> { ) -> Result<AgentMutationResult, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
let updated_at = OffsetDateTime::now_utc(); let updated_at = OffsetDateTime::now_utc();
self.registry self.registry
.unpublish_agent(workspace_id, agent_id, &updated_at) .unpublish_agent(workspace_id, agent_id, &updated_at, expected_state)
.await?; .await?;
info!(agent_id = %agent_id.as_str(), "agent moved to draft"); info!(
name: "admin.agent.unpublished",
agent_id = %agent_id.as_str(),
"agent moved to draft"
);
Ok(AgentMutationResult { Ok(AgentMutationResult {
agent_id: agent_id.as_str().to_owned(), agent_id: agent_id.as_str().to_owned(),
@@ -475,13 +703,18 @@ impl AdminService {
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> { ) -> Result<AgentMutationResult, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
let updated_at = OffsetDateTime::now_utc(); let updated_at = OffsetDateTime::now_utc();
self.registry self.registry
.archive_agent(workspace_id, agent_id, &updated_at) .archive_agent(workspace_id, agent_id, &updated_at, expected_state)
.await?; .await?;
info!(agent_id = %agent_id.as_str(), "agent archived"); info!(
name: "admin.agent.archived",
agent_id = %agent_id.as_str(),
"agent archived"
);
Ok(AgentMutationResult { Ok(AgentMutationResult {
agent_id: agent_id.as_str().to_owned(), agent_id: agent_id.as_str().to_owned(),
@@ -490,3 +723,22 @@ impl AdminService {
}) })
} }
} }
fn validate_tool_selection_policy(
policy: &ToolSelectionPolicy,
bindings: &[AgentOperationBinding],
) -> Result<(), ApiError> {
policy
.validate_for_tools(
bindings
.iter()
.filter(|binding| binding.enabled)
.map(|binding| binding.tool_name.as_str()),
)
.map_err(|error| {
ApiError::validation_with_context(
error.to_string(),
json!({"field": "tool_selection_policy"}),
)
})
}
+302 -33
View File
@@ -6,12 +6,14 @@ use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord};
use serde_json::json; use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::instrument; use tracing::instrument;
use url::Url;
use crate::{ use crate::{
error::ApiError, error::ApiError,
service::{ service::{
AdminService, CreatedPlatformApiKeyResponse, PlatformApiKeyPayload, generate_access_secret, AdminAuditContext, AdminService, CreatedPlatformApiKeyResponse, CredentialAuditRecord,
hash_access_secret, new_prefixed_id, EphemeralMcpClientConfig, EphemeralMcpConnection, PlatformApiKeyPayload,
generate_access_secret, hash_access_secret, new_prefixed_id,
}, },
}; };
@@ -38,37 +40,86 @@ impl AdminService {
.await?) .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( pub async fn create_agent_platform_api_key(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
payload: PlatformApiKeyPayload, mut payload: PlatformApiKeyPayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<CreatedPlatformApiKeyResponse, ApiError> { ) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; let workspace = match self.get_workspace(workspace_id).await {
self.registry 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) .get_agent_summary(workspace_id, agent_id)
.await? .await
.ok_or_else(|| { {
Ok(agent) => agent.ok_or_else(|| {
ApiError::not_found_with_context( ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()), format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }), json!({ "agent_id": agent_id.as_str() }),
) )
})?; }),
Err(error) => Err(ApiError::from(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 secret = generate_access_secret(match payload.key_kind { let agent = match agent_result {
PlatformApiKeyKind::McpClient => "crk", Ok(agent) => agent,
PlatformApiKeyKind::Approval => "crk_appr", 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 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 { let api_key = PlatformApiKeyRecord {
api_key: PlatformApiKey { api_key: PlatformApiKey {
id: PlatformApiKeyId::new(new_prefixed_id("pk")), id: PlatformApiKeyId::new(new_prefixed_id("pk")),
@@ -86,55 +137,213 @@ impl AdminService {
}, },
}; };
self.registry if let Err(error) = self
.registry
.create_platform_api_key(CreatePlatformApiKeyRequest { .create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &api_key.api_key, api_key: &api_key.api_key,
secret_hash: &hash_access_secret(&secret), 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( pub async fn revoke_agent_platform_api_key(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
key_id: &PlatformApiKeyId, key_id: &PlatformApiKeyId,
audit_context: Option<&AdminAuditContext>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
self.registry if let Err(error) = self
.registry
.revoke_platform_api_key_for_agent( .revoke_platform_api_key_for_agent(
workspace_id, workspace_id,
agent_id, agent_id,
key_id, key_id,
&OffsetDateTime::now_utc(), &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(()) 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( pub async fn delete_agent_platform_api_key(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
agent_id: &AgentId, agent_id: &AgentId,
key_id: &PlatformApiKeyId, key_id: &PlatformApiKeyId,
audit_context: Option<&AdminAuditContext>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
self.registry if let Err(error) = self
.registry
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id) .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(()) Ok(())
} }
} }
fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> { fn ephemeral_client_configs(endpoint: &str, secret: &str) -> Vec<EphemeralMcpClientConfig> {
["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<Option<OffsetDateTime>, 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() { if payload.name.trim().is_empty() {
return Err(ApiError::validation("key name is required")); 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() { if payload.scopes.is_empty() {
return Err(ApiError::validation("at least one key scope is required")); 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 { let valid = payload.scopes.iter().all(|scope| match payload.key_kind {
PlatformApiKeyKind::McpClient => matches!( PlatformApiKeyKind::McpClient => matches!(
@@ -159,6 +368,66 @@ fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<
"approval key can contain at most 20 allowed origins", "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<String, ApiError> {
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())
} }
+230 -27
View File
@@ -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 serde_json::json;
use time::OffsetDateTime;
use tracing::instrument; use tracing::instrument;
use crate::{ use crate::{
auth::{ auth::{
AuthenticatedSession, SessionCookie, create_session_cookie, hash_password, AuthenticatedSession, SessionCookie, create_csrf_token_value, create_session_cookie,
hash_session_secret, verify_password, hash_csrf_token, hash_password, hash_session_secret, verify_password,
}, },
error::ApiError, error::ApiError,
service::{ service::{
AdminService, ChangePasswordPayload, LoginPayload, SessionResponse, UpdateProfilePayload, AdminService, BootstrapStatusResponse, ChangePasswordPayload, CompleteBootstrapPayload,
map_identity_error, validate_profile_display_name, validate_profile_email, LoginPayload, SessionResponse, UpdateProfilePayload, hash_access_secret,
map_identity_error, new_prefixed_id, validate_profile_display_name, validate_profile_email,
}, },
}; };
impl AdminService { impl AdminService {
pub async fn bootstrap_status(&self) -> Result<BootstrapStatusResponse, ApiError> {
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> { pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> {
let password_hash = hash_password( let password_hash = hash_password(
&self.auth_settings.bootstrap_admin.password, &self.auth_settings.bootstrap_admin.password,
@@ -22,7 +103,7 @@ impl AdminService {
)?; )?;
let user_id = self let user_id = self
.registry .registry
.upsert_bootstrap_user( .ensure_bootstrap_user(
&self.auth_settings.bootstrap_admin.email, &self.auth_settings.bootstrap_admin.email,
&self.auth_settings.bootstrap_admin.display_name, &self.auth_settings.bootstrap_admin.display_name,
&password_hash, &password_hash,
@@ -83,36 +164,99 @@ impl AdminService {
Ok(()) Ok(())
} }
pub async fn verify_session_csrf(
&self,
session_id: &UserSessionId,
csrf_hash: &str,
) -> Result<bool, ApiError> {
Ok(self
.registry
.verify_session_csrf(session_id, csrf_hash)
.await?)
}
pub async fn login( pub async fn login(
&self, &self,
payload: LoginPayload, payload: LoginPayload,
) -> Result<(SessionCookie, SessionResponse), ApiError> { ) -> 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 session_cookie = create_session_cookie(&self.auth_settings)?;
let csrf_token = create_csrf_token_value();
let secret_hash = hash_session_secret( let secret_hash = hash_session_secret(
&session_cookie.session_id, &session_cookie.session_id,
&session_cookie.value, &session_cookie.value,
&self.auth_settings.session_secret, &self.auth_settings.session_secret,
); );
let memberships = self let csrf_hash = hash_csrf_token(
.registry &session_cookie.session_id,
.list_workspaces_for_user(&authenticated.user.id) &csrf_token,
.await?; &self.auth_settings.session_secret,
);
let memberships = self.registry.list_workspaces_for_user(&user.id).await?;
let default_workspace_id = memberships let default_workspace_id = memberships
.iter() .iter()
.find(|membership| membership.workspace.id.as_str() == "ws_default") .find(|membership| membership.workspace.id.as_str() == "ws_default")
.map(|membership| membership.workspace.id.as_str().to_owned()); .map(|membership| membership.workspace.id.as_str().to_owned());
let current_workspace_id = default_workspace_id.or_else(|| { 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 memberships
.first() .first()
.map(|membership| membership.workspace.id.as_str().to_owned()) .map(|membership| membership.workspace.id.as_str().to_owned())
})
}); });
let current_workspace_ref = current_workspace_id let current_workspace_ref = current_workspace_id
.as_ref() .as_ref()
@@ -120,9 +264,10 @@ impl AdminService {
self.registry self.registry
.create_user_session( .create_user_session(
&session_cookie.session_id, &session_cookie.session_id,
&authenticated.user.id, &user.id,
current_workspace_ref.as_ref(), current_workspace_ref.as_ref(),
&secret_hash, &secret_hash,
Some(&csrf_hash),
&session_cookie.expires_at, &session_cookie.expires_at,
) )
.await?; .await?;
@@ -130,9 +275,10 @@ impl AdminService {
Ok(( Ok((
session_cookie, session_cookie,
SessionResponse { SessionResponse {
user: authenticated.user, user,
memberships, memberships,
current_workspace_id, current_workspace_id,
csrf_token,
}, },
)) ))
} }
@@ -158,7 +304,19 @@ impl AdminService {
.registry .registry
.get_auth_user_by_email(&payload.email) .get_auth_user_by_email(&payload.email)
.await? .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( if !verify_password(
&payload.password, &payload.password,
@@ -181,6 +339,8 @@ impl AdminService {
_session_value: &str, _session_value: &str,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
self.registry.revoke_user_session(session_id).await?; self.registry.revoke_user_session(session_id).await?;
self.record_admin_security_audit(None, "logout", "success", "session")
.await;
Ok(()) Ok(())
} }
@@ -190,21 +350,23 @@ impl AdminService {
session_id: &UserSessionId, session_id: &UserSessionId,
session_value: &str, session_value: &str,
) -> Result<Option<SessionResponse>, ApiError> { ) -> Result<Option<SessionResponse>, ApiError> {
Ok(self let Some(session) = self.get_session(session_id, session_value).await? else {
.get_session(session_id, session_value) return Ok(None);
.await? };
.map(|session| SessionResponse { Ok(Some(SessionResponse {
user: session.user, user: session.user,
memberships: session.memberships, memberships: session.memberships,
current_workspace_id: session current_workspace_id: session
.current_workspace_id .current_workspace_id
.map(|id| id.as_str().to_owned()), .map(|id| id.as_str().to_owned()),
csrf_token: String::new(),
})) }))
} }
pub async fn update_profile( pub async fn update_profile(
&self, &self,
user_id: &crank_core::UserId, user_id: &crank_core::UserId,
session_id: &UserSessionId,
current_workspace_id: Option<&WorkspaceId>, current_workspace_id: Option<&WorkspaceId>,
payload: UpdateProfilePayload, payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> { ) -> Result<SessionResponse, ApiError> {
@@ -221,17 +383,19 @@ impl AdminService {
user, user,
memberships, memberships,
current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()), 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( pub async fn change_password(
&self, &self,
user_id: &crank_core::UserId, user_id: &crank_core::UserId,
_current_session_id: &UserSessionId,
payload: ChangePasswordPayload, payload: ChangePasswordPayload,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
if payload.new_password.len() < 12 { if payload.new_password.len() < 12 || payload.new_password.len() > 256 {
return Err(ApiError::validation( return Err(ApiError::validation(
"new password must be at least 12 characters long", "new password must be between 12 and 256 characters long",
)); ));
} }
@@ -257,9 +421,48 @@ impl AdminService {
let password_hash = let password_hash =
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?; hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
self.registry self.registry
.update_user_password(user_id, &password_hash) .update_user_password_and_revoke_all_sessions(user_id, &password_hash)
.await?; .await?;
self.record_admin_security_audit(Some(user_id), "password_rotated", "success", "password")
.await;
Ok(()) 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<String, ApiError> {
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)
}
} }
+65 -15
View File
@@ -1,9 +1,9 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crank_core::{ use crank_core::{
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId, AgentId, AgentOperationBinding, InvocationLevel, InvocationSource, InvocationStatus,
OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, MembershipRole, OperationId, OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope,
Protocol, Target, WizardState, WorkspaceId, PlatformApiKeyStatus, Protocol, Target, WizardState, WorkspaceId,
}; };
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples}; use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
use crank_mapping::{MappingRule, MappingSet}; use crank_mapping::{MappingRule, MappingSet};
@@ -87,7 +87,11 @@ impl AdminService {
async fn cleanup_legacy_demo_assets(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> { async fn cleanup_legacy_demo_assets(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> {
for slug in ["revops-copilot", "support-triage"] { for slug in ["revops-copilot", "support-triage"] {
if let Some(agent) = self.find_agent_by_slug(workspace_id, slug).await? { 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())) self.delete_agent(
workspace_id,
&AgentId::new(agent.id.as_str().to_owned()),
None,
)
.await?; .await?;
} }
} }
@@ -129,8 +133,12 @@ impl AdminService {
.await .await
{ {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => { Err(
RegistryError::OperationHasPublishedAgentBindings { .. }
| RegistryError::OperationDeleteForbidden { .. },
) => {
tracing::warn!( tracing::warn!(
name: "admin.demo_operation.cleanup_skipped",
operation_id = %operation_id.as_str(), operation_id = %operation_id.as_str(),
"legacy demo operation is still bound to a published agent; leaving it in place" "legacy demo operation is still bound to a published agent; leaving it in place"
); );
@@ -167,6 +175,7 @@ impl AdminService {
expires_at: None, expires_at: None,
allowed_origins: Vec::new(), allowed_origins: Vec::new(),
}, },
None,
) )
.await? .await?
.api_key .api_key
@@ -174,7 +183,7 @@ impl AdminService {
}; };
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked { 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?; .await?;
} }
@@ -209,7 +218,16 @@ impl AdminService {
return Ok(()); return Ok(());
} }
self.publish_operation(workspace_id, &summary.id, summary.current_draft_version) 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?; .await?;
Ok(()) Ok(())
} }
@@ -272,10 +290,15 @@ impl AdminService {
publish: bool, publish: bool,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let summary = self.get_agent(workspace_id, agent_id).await?; let summary = self.get_agent(workspace_id, agent_id).await?;
self.save_agent_bindings(workspace_id, agent_id, bindings) let current = self
.get_agent_version(workspace_id, agent_id, summary.current_draft_version)
.await?; .await?;
if !demo_agent_bindings_match(&current.bindings, &bindings) {
self.save_agent_bindings(workspace_id, agent_id, bindings.into(), None)
.await?;
}
if publish && summary.latest_published_version.is_none() { 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?; .await?;
} }
@@ -293,11 +316,16 @@ impl AdminService {
.list_invocation_logs(ListInvocationLogsQuery { .list_invocation_logs(ListInvocationLogsQuery {
workspace_id, workspace_id,
level: None, level: None,
status: None,
outcome_group: None,
search_text: None, search_text: None,
source: None, source: None,
operation_id: None, operation_id: None,
agent_id: None, agent_id: None,
created_after: None, created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 1, limit: 1,
}) })
.await? .await?
@@ -315,17 +343,23 @@ impl AdminService {
.current_draft_version, .current_draft_version,
) )
.await?; .await?;
let correlation = crank_core::CorrelationContext::generate();
self.record_invocation(InvocationRecordRequest { self.record_invocation(InvocationRecordRequest {
workspace_id, workspace_id,
agent_id: Some(currency_agent_id), agent_id: Some(currency_agent_id),
operation: &rest_operation.snapshot, operation: &rest_operation.snapshot,
request_id: None, request_id: Some(correlation.request_id().as_str()),
trace_id: Some(correlation.trace_id().as_str()),
source: InvocationSource::AgentToolCall, source: InvocationSource::AgentToolCall,
level: InvocationLevel::Info, level: InvocationLevel::Info,
status: InvocationStatus::Ok, status: InvocationStatus::Ok,
message: "Frankfurter returned latest exchange rate".to_owned(), message: "Frankfurter returned latest exchange rate".to_owned(),
status_code: Some(200), status_code: Some(200),
error_kind: 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: 124, duration_ms: 124,
request_preview: json!({ request_preview: json!({
"path": {}, "path": {},
@@ -335,11 +369,30 @@ impl AdminService {
}), }),
response_preview: demo_rest_response_sample(), response_preview: demo_rest_response_sample(),
}) })
.await?; .await;
Ok(()) Ok(())
} }
} }
fn demo_agent_bindings_match(
existing: &[AgentOperationBinding],
desired: &[AgentBindingPayload],
) -> bool {
if existing.len() != desired.len() {
return false;
}
let mut desired = desired.iter().collect::<Vec<_>>();
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 { fn demo_currency_agent_payload() -> AgentPayload {
AgentPayload { AgentPayload {
slug: "currency-rates".to_owned(), slug: "currency-rates".to_owned(),
@@ -348,10 +401,7 @@ fn demo_currency_agent_payload() -> AgentPayload {
instructions: json!({ instructions: json!({
"system": "Используй инструменты Frankfurter только для запросов о курсах валют." "system": "Используй инструменты Frankfurter только для запросов о курсах валют."
}), }),
tool_selection_policy: json!({ tool_selection_policy: Default::default(),
"max_tools": 4,
"prefer_tag": ["currency", "exchange-rate"]
}),
} }
} }
+498 -44
View File
@@ -1,16 +1,26 @@
use crank_core::{ConfigExport, WorkspaceId}; use crank_core::{OperationStatus, Target, WorkspaceId};
use crank_registry::RegistryOperation; 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 tracing::{info, instrument};
use crate::{ use crate::{
error::ApiError, error::ApiError,
import_guidance::import_guidance_warnings, import_guidance::import_guidance_warnings,
service::{ service::{
AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse, NewVersionPayload, AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse,
OperationPayload, YamlOperationDocument, 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 { impl AdminService {
#[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))] #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))]
pub async fn export_operation( pub async fn export_operation(
@@ -30,19 +40,30 @@ impl AdminService {
let record = self let record = self
.get_operation_version(workspace_id, operation_id, version) .get_operation_version(workspace_id, operation_id, version)
.await?; .await?;
let document = YamlOperationDocument { let aggregate = self.get_operation(workspace_id, operation_id).await?;
format_version: "1".to_owned(), let exportable = record.status == OperationStatus::Published
kind: "operation".to_owned(), || (aggregate.status != OperationStatus::Archived
operation: RegistryOperation { && record.status == OperationStatus::Draft
config_export: Some(ConfigExport { && aggregate.current_draft_version == version);
format_version: "1".to_owned(), if !exportable {
export_mode: query.mode, 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))] #[instrument(skip(self, yaml_document), fields(mode = ?query.mode))]
@@ -51,29 +72,41 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
query: ImportQuery, query: ImportQuery,
yaml_document: &str, yaml_document: &str,
if_match: Option<&str>,
) -> Result<ImportResponse, ApiError> { ) -> Result<ImportResponse, ApiError> {
let document: YamlOperationDocument = serde_yaml::from_str(yaml_document) reject_yaml_anchors_and_aliases(yaml_document)?;
.map_err(|error| ApiError::validation(error.to_string()))?; let parsed = parse_bounded_yaml(yaml_document)?;
if document.kind != "operation" { let format_version = parsed
return Err(ApiError::validation("yaml kind must be operation")); .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 { let (operation, warnings) = match format_version {
name: document.operation.name.clone(), "2" => {
display_name: document.operation.display_name.clone(), let document: YamlOperationDocument =
category: document.operation.category.clone(), serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?;
protocol: document.operation.protocol, if document.kind != "operation" || document.format_version != "2" {
security_level: document.operation.security_level, return Err(yaml_unsupported());
target: document.operation.target.clone(), }
input_schema: document.operation.input_schema.clone(), (document.operation, Vec::new())
output_schema: document.operation.output_schema.clone(), }
input_mapping: document.operation.input_mapping.clone(), "1" => {
output_mapping: document.operation.output_mapping.clone(), let document: LegacyYamlOperationDocument =
execution_config: document.operation.execution_config.clone(), serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?;
tool_description: document.operation.tool_description.clone(), if document.kind != "operation" || document.format_version != "1" {
wizard_state: document.operation.wizard_state.clone(), return Err(yaml_unsupported());
}; }
let warnings = import_guidance_warnings(&document.operation); let warnings = import_guidance_warnings(&document.operation);
(
PortableOperation::from_registry(&document.operation),
warnings,
)
}
_ => return Err(yaml_unsupported()),
};
reject_credential_material(&operation)?;
let payload = operation.to_payload();
match query.mode { match query.mode {
ImportMode::Create => { ImportMode::Create => {
@@ -88,9 +121,66 @@ impl AdminService {
} }
ImportMode::Upsert => { ImportMode::Upsert => {
if let Some(existing) = self if let Some(existing) = self
.find_operation_by_name(workspace_id, &document.operation.name) .find_operation_by_name(workspace_id, &operation.name)
.await? .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(&current.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(&current_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 let created = self
.create_version( .create_version(
workspace_id, workspace_id,
@@ -99,9 +189,9 @@ impl AdminService {
operation: payload, operation: payload,
change_note: Some("yaml upsert".to_owned()), change_note: Some("yaml upsert".to_owned()),
}, },
&expected_state,
) )
.await?; .await?;
let response = ImportResponse { let response = ImportResponse {
operation_id: created.operation_id, operation_id: created.operation_id,
workspace_id: created.workspace_id, workspace_id: created.workspace_id,
@@ -110,6 +200,7 @@ impl AdminService {
warnings, warnings,
}; };
info!( info!(
name: "admin.operation.imported",
operation_id = %response.operation_id, operation_id = %response.operation_id,
version = response.version, version = response.version,
"operation imported by upsert" "operation imported by upsert"
@@ -117,21 +208,384 @@ impl AdminService {
Ok(response) Ok(response)
} else { } else {
let created = self.create_operation(workspace_id, payload).await?; let created = self.create_operation(workspace_id, payload).await?;
let response = ImportResponse { Ok(ImportResponse {
operation_id: created.operation_id, operation_id: created.operation_id,
workspace_id: created.workspace_id, workspace_id: created.workspace_id,
version: created.version, version: created.version,
import_mode: ImportMode::Upsert, import_mode: ImportMode::Upsert,
warnings, warnings,
}; })
info!(
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<YamlValue, ApiError> {
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<D>(deserializer: D) -> Result<Self, D::Error>
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<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut keys = Vec::<YamlValue>::new();
while let Some(key) = map.next_key::<YamlValue>()? {
if keys.iter().any(|existing| existing == &key) {
return Err(de::Error::custom("duplicate mapping key"));
}
keys.push(key);
map.next_value::<DuplicateChecked>()?;
}
Ok(DuplicateChecked)
}
fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
while sequence.next_element::<DuplicateChecked>()?.is_some() {}
Ok(DuplicateChecked)
}
fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_str<E>(self, _: &str) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_string<E>(self, _: String) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_none<E>(self) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_unit<E>(self) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
DuplicateChecked::deserialize(deserializer)
}
fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
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::<Vec<_>>();
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::<Vec<_>>();
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:?}"),
}
}
}
+672 -92
View File
@@ -1,16 +1,22 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use crank_artifacts::{ArtifactError, MAX_ARTIFACT_BYTES};
use crank_core::{ use crank_core::{
ExecutionConfig, OperationSecurityLevel, Protocol, ToolQualityFinding, ToolQualitySeverity, ExecutionConfig, OperationSecurityLevel, Protocol, ToolQualityFinding, ToolQualitySeverity,
WorkspaceId, WorkspaceId,
}; };
use crank_import::rest::{ use crank_import::rest::{
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate, ImportFinding, ImportFindingSeverity, ImportOperationCandidate, NORMALIZER_VERSION,
PROJECTION_VERSION, operation_draft_from_candidate,
}; };
use crank_registry::{ use crank_registry::{
CreateImportJobRequest, FinishImportJobRequest, ImportJobId, ImportJobKind, ImportJobStatus, ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity,
CreateArtifactSourceRequest, CreateImportJobRequest, FinishImportJobRequest,
ImportConflictMode, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobSourceEnvelope,
ImportJobStatus, ImportOperationDraft, RegistryError,
}; };
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, instrument}; use tracing::{info, instrument};
@@ -18,51 +24,167 @@ use crate::{
error::ApiError, error::ApiError,
service::{ service::{
AdminService, OpenApiImportCreatePayload, OpenApiImportCreateResponse, AdminService, OpenApiImportCreatePayload, OpenApiImportCreateResponse,
OpenApiImportCreatedOperation, OpenApiImportPreviewPayload, OpenApiImportPreviewResponse, OpenApiImportCreatedOperation, OpenApiImportPreviewResponse, OpenApiImportSkippedOperation,
OpenApiImportSkippedOperation, OperationPayload, new_prefixed_id, OpenApiUpload, OpenApiUploadLocale, OperationPayload, new_prefixed_id,
}, },
}; };
mod external_references;
mod job_contract;
use external_references::ImportReplayContext;
use job_contract::{
SourceDetachGuard, import_job_dependencies, import_job_normalization_config, import_job_source,
};
const IMPORT_JOB_TTL_HOURS: i64 = 24; const IMPORT_JOB_TTL_HOURS: i64 = 24;
const OPENAPI_PARSE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
const OPENAPI_PARSE_CONCURRENCY: usize = 4;
static OPENAPI_PARSE_SLOTS: tokio::sync::Semaphore =
tokio::sync::Semaphore::const_new(OPENAPI_PARSE_CONCURRENCY);
impl AdminService { impl AdminService {
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))] #[instrument(skip(self, upload), fields(workspace_id = %workspace_id.as_str()))]
pub async fn preview_openapi_import( pub async fn preview_openapi_import(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
payload: OpenApiImportPreviewPayload, upload: OpenApiUpload,
) -> Result<OpenApiImportPreviewResponse, ApiError> { ) -> Result<OpenApiImportPreviewResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
let _ = self.registry.delete_expired_import_jobs().await;
let preview = crank_import::rest::preview_document(&payload.document) validate_openapi_upload(&upload)?;
.map_err(|error| ApiError::validation(error.to_string()))?; let OpenApiUpload {
bytes,
mime_type,
locale,
} = upload;
let store = self.artifact_store.clone();
let artifact = tokio::task::spawn_blocking(move || store.put_registered(&bytes))
.await
.map_err(|_| ApiError::openapi_upload(locale, "storage_unavailable"))?
.map_err(|error| artifact_error(locale, error))?;
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let expires_at = now + Duration::hours(IMPORT_JOB_TTL_HOURS); let expires_at = now + Duration::hours(IMPORT_JOB_TTL_HOURS);
let job_id = ImportJobId::new(new_prefixed_id("imp")); let job_id = ImportJobId::new(new_prefixed_id("imp"));
let preview_payload = serde_json::to_value(&preview) let source_id = ArtifactSourceId::new(new_prefixed_id("src_openapi"));
let source = self
.registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id,
source_id: &source_id,
artifact: &artifact,
mime_type: &mime_type,
sensitivity: ArtifactSourceSensitivity::Internal,
created_at: now,
})
.await?;
let mut detach_guard = SourceDetachGuard::new(
self.registry.clone(),
workspace_id.clone(),
source_id.clone(),
source.updated_at,
);
let verified = match self
.registry
.read_artifact_source(
std::sync::Arc::clone(&self.artifact_store),
workspace_id,
&source_id,
)
.await
{
Ok(verified) => verified,
Err(error) => {
detach_guard.detach_now().await;
return Err(ApiError::from(error));
}
};
if verified.source.blob.artifact_ref != *artifact.artifact_ref() {
detach_guard.detach_now().await;
return Err(ApiError::openapi_upload(locale, "source_integrity"));
}
let mut dependencies = self
.materialize_external_reference_snapshots(workspace_id, &verified.bytes, now)
.await;
let parsed = match parse_verified_preview(
verified.bytes,
artifact.artifact_ref().digest_hex().to_owned(),
dependencies.snapshots.clone(),
self.external_reference_normalization.clone(),
locale,
false,
)
.await
{
Ok(preview) => preview,
Err(error) => {
dependencies.detach_all().await;
detach_guard.detach_now().await;
return Err(error);
}
};
if parsed
.preview
.groups
.iter()
.all(|group| group.operations.is_empty())
{
dependencies.detach_all().await;
detach_guard.detach_now().await;
return Err(ApiError::openapi_upload(locale, "no_methods"));
}
let source_envelope = ImportJobSourceEnvelope {
source_id,
digest: artifact.artifact_ref().clone(),
};
let preview_value = serde_json::to_value(&parsed.preview)
.map_err(|error| ApiError::internal(error.to_string()))?; .map_err(|error| ApiError::internal(error.to_string()))?;
let preview_digest = preview_digest(&preview_value)?;
let preview_payload = json!({
"source": {
"source_id": source_envelope.source_id.as_str(),
"digest": source_envelope.digest.as_str(),
},
"dependencies": dependencies.payload(),
"dependency_snapshots": dependencies.snapshot_payload(),
"preview": preview_value,
"preview_digest": preview_digest,
"normalization": {
"normalizer_version": NORMALIZER_VERSION,
"projection_version": PROJECTION_VERSION,
"ir_fingerprint": parsed.ir_fingerprint,
"config": self.external_reference_normalization,
},
});
self.registry if let Err(error) = self
.registry
.create_import_job(CreateImportJobRequest { .create_import_job(CreateImportJobRequest {
id: &job_id, id: &job_id,
workspace_id, workspace_id,
kind: ImportJobKind::OpenApi, kind: ImportJobKind::OpenApi,
source_format: &preview.source.format, source_format: &parsed.preview.source.format,
source_version: preview.source.version.as_deref(), source_version: parsed.preview.source.version.as_deref(),
status: ImportJobStatus::Completed, status: ImportJobStatus::Pending,
source: &source_envelope,
preview_payload: &preview_payload, preview_payload: &preview_payload,
created_at: &now, created_at: &now,
expires_at: &expires_at, expires_at: &expires_at,
}) })
.await?; .await
{
dependencies.detach_all().await;
detach_guard.detach_now().await;
return Err(ApiError::from(error));
}
dependencies.disarm();
detach_guard.disarm();
Ok(OpenApiImportPreviewResponse { Ok(OpenApiImportPreviewResponse {
job_id: job_id.as_str().to_owned(), job_id: job_id.as_str().to_owned(),
expires_at: expires_at expires_at: expires_at
.format(&Rfc3339) .format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?, .map_err(|error| ApiError::internal(error.to_string()))?,
preview, preview: parsed.preview,
}) })
} }
@@ -72,9 +194,24 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
job_id: &ImportJobId, job_id: &ImportJobId,
payload: OpenApiImportCreatePayload, payload: OpenApiImportCreatePayload,
) -> Result<OpenApiImportCreateResponse, ApiError> {
self.create_openapi_import_with_locale(
workspace_id,
job_id,
payload,
OpenApiUploadLocale::En,
)
.await
}
pub async fn create_openapi_import_with_locale(
&self,
workspace_id: &WorkspaceId,
job_id: &ImportJobId,
payload: OpenApiImportCreatePayload,
locale: OpenApiUploadLocale,
) -> Result<OpenApiImportCreateResponse, ApiError> { ) -> Result<OpenApiImportCreateResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
let _ = self.registry.delete_expired_import_jobs().await;
if !matches!(payload.conflict_mode.as_str(), "skip" | "rename") { if !matches!(payload.conflict_mode.as_str(), "skip" | "rename") {
return Err(ApiError::validation( return Err(ApiError::validation(
@@ -99,9 +236,6 @@ impl AdminService {
return Err(ApiError::validation("import job kind is not openapi")); return Err(ApiError::validation("import job kind is not openapi"));
} }
let preview: crank_import::rest::ImportPreview =
serde_json::from_value(job.preview_payload.clone())
.map_err(|error| ApiError::internal(error.to_string()))?;
let selected = payload let selected = payload
.selected_operation_keys .selected_operation_keys
.iter() .iter()
@@ -112,22 +246,192 @@ impl AdminService {
"selected_operation_keys must contain at least one operation", "selected_operation_keys must contain at least one operation",
)); ));
} }
let finished_at = OffsetDateTime::now_utc();
let application_key = openapi_application_key(&payload)?;
let conflict_mode = if payload.conflict_mode == "skip" {
ImportConflictMode::Skip
} else {
ImportConflictMode::Rename
};
let replay_context = ImportReplayContext {
workspace_id,
job_id,
application_key: &application_key,
conflict_mode,
finished_at: &finished_at,
};
if job.status == ImportJobStatus::Completed {
let applied = self
.registry
.apply_import_job(ApplyImportJobRequest {
id: job_id,
workspace_id,
application_key: &application_key,
conflict_mode,
operations: &[],
pre_skipped: &[],
finished_at: &finished_at,
})
.await?;
return Ok(openapi_import_response(applied));
}
let source = match import_job_source(&job.preview_payload, locale) {
Ok(source) => source,
Err(error) => {
return self
.fail_openapi_import_or_replay(
&replay_context,
"import_source_verification_failed",
error,
)
.await;
}
};
let verified = match self
.registry
.read_artifact_source(
std::sync::Arc::clone(&self.artifact_store),
workspace_id,
&source.source_id,
)
.await
{
Ok(verified) => verified,
Err(
error @ (RegistryError::SourceNotFound { .. } | RegistryError::SourceUnavailable),
) => {
return self
.fail_openapi_import_or_replay(
&replay_context,
"import_source_verification_failed",
openapi_source_error(locale, error),
)
.await;
}
Err(error @ RegistryError::SourceIntegrity) => {
return self
.fail_openapi_import_or_replay(
&replay_context,
"import_source_verification_failed",
openapi_source_error(locale, error),
)
.await;
}
Err(error) => return Err(openapi_source_error(locale, error)),
};
if verified.source.blob.artifact_ref != source.digest {
return self
.fail_openapi_import_or_replay(
&replay_context,
"import_source_verification_failed",
ApiError::openapi_upload(locale, "source_integrity"),
)
.await;
}
let replay_contract = match import_replay_contract(&job.preview_payload, locale) {
Ok(contract) => contract,
Err(error) => {
return self
.fail_openapi_import_or_replay(
&replay_context,
"import_replay_verification_failed",
error,
)
.await;
}
};
let parsed = match replay_contract {
ImportReplayContract::PersistedV2(parsed) => parsed,
ImportReplayContract::LegacyV1 | ImportReplayContract::Current => {
let legacy_v1 = matches!(replay_contract, ImportReplayContract::LegacyV1);
let dependency_snapshots = match self
.read_import_job_dependencies(workspace_id, &job.preview_payload, locale)
.await
{
Ok(snapshots) => snapshots,
Err(error) => {
return self
.fail_openapi_import_or_replay(
&replay_context,
"import_dependency_verification_failed",
error,
)
.await;
}
};
let normalization_config = match import_job_normalization_config(
&job.preview_payload,
&self.external_reference_normalization,
legacy_v1,
locale,
) {
Ok(config) => config,
Err(error) => {
return self
.fail_openapi_import_or_replay(
&replay_context,
"import_replay_verification_failed",
error,
)
.await;
}
};
let parsed = match parse_verified_preview(
verified.bytes,
source.digest.digest_hex().to_owned(),
dependency_snapshots,
normalization_config,
locale,
legacy_v1,
)
.await
{
Ok(parsed) => parsed,
Err(error) => {
return self
.fail_openapi_import_or_replay(
&replay_context,
"import_replay_verification_failed",
error,
)
.await;
}
};
if let Err(error) = verify_preview_contract(&job.preview_payload, &parsed, locale) {
return self
.fail_openapi_import_or_replay(
&replay_context,
"import_replay_verification_failed",
error,
)
.await;
}
parsed
}
};
if preview_has_blocker(&parsed.preview, &selected) {
return self
.fail_openapi_import_or_replay(
&replay_context,
"reference_resolution_blocked",
ApiError::openapi_upload(locale, "invalid_document"),
)
.await;
}
let mut candidates = BTreeMap::new(); let mut candidates = BTreeMap::new();
for group in &preview.groups { for group in &parsed.preview.groups {
for operation in &group.operations { for operation in &group.operations {
candidates.insert(operation.key.clone(), operation); candidates.insert(operation.key.clone(), operation);
} }
} }
let mut created = Vec::new();
let mut skipped = Vec::new(); let mut skipped = Vec::new();
let mut findings = Vec::new(); let mut operations = Vec::new();
let mut created_ids = Vec::new();
for operation_key in selected { for operation_key in selected {
let Some(candidate) = candidates.get(&operation_key) else { let Some(candidate) = candidates.get(&operation_key) else {
skipped.push(OpenApiImportSkippedOperation { skipped.push(crank_registry::SkippedImportOperation {
operation_key, operation_key,
name: String::new(), name: String::new(),
reason: "operation was not found in import preview".to_owned(), reason: "operation was not found in import preview".to_owned(),
@@ -137,44 +441,7 @@ impl AdminService {
let mut draft = let mut draft =
operation_draft_from_candidate(candidate, payload.server_url.as_deref()); operation_draft_from_candidate(candidate, payload.server_url.as_deref());
attach_import_findings(&mut draft, candidate); attach_import_findings(&mut draft, candidate);
if let Some(existing_name) = self let operation = self.new_operation_snapshot(OperationPayload {
.find_operation_by_name(workspace_id, &draft.name)
.await?
.map(|operation| operation.name)
{
if payload.conflict_mode == "skip" {
skipped.push(OpenApiImportSkippedOperation {
operation_key: candidate.key.clone(),
name: draft.name.clone(),
reason: "operation with this name already exists".to_owned(),
});
findings.push(ImportFinding {
code: "operation_name_conflict".to_owned(),
severity: ImportFindingSeverity::Warning,
message: format!(
"Операция {} уже существует и была пропущена.",
draft.name
),
operation_key: Some(candidate.key.clone()),
});
continue;
}
let renamed = self
.next_available_operation_name(workspace_id, &draft.name)
.await?;
findings.push(ImportFinding {
code: "operation_name_renamed".to_owned(),
severity: ImportFindingSeverity::Info,
message: format!(
"Операция {existing_name} уже существует, новый черновик создан как {renamed}."
),
operation_key: Some(candidate.key.clone()),
});
draft.name = renamed;
}
let payload = OperationPayload {
name: draft.name.clone(), name: draft.name.clone(),
display_name: draft.display_name.clone(), display_name: draft.display_name.clone(),
category: draft.category, category: draft.category,
@@ -197,57 +464,370 @@ impl AdminService {
}, },
tool_description: draft.tool_description, tool_description: draft.tool_description,
wizard_state: draft.wizard_state, wizard_state: draft.wizard_state,
}; })?;
let result = self.create_operation(workspace_id, payload).await?; operations.push(ImportOperationDraft {
created_ids.push(result.operation_id.clone()); operation_key: candidate.key.clone(),
created.push(OpenApiImportCreatedOperation { operation,
operation_id: result.operation_id,
name: draft.name,
version: result.version,
}); });
} }
let finished_at = OffsetDateTime::now_utc(); let applied = self
self.registry .registry
.finish_import_job(FinishImportJobRequest { .apply_import_job(ApplyImportJobRequest {
id: job_id, id: job_id,
status: ImportJobStatus::Completed, workspace_id,
created_operation_ids: &json!(created_ids), application_key: &application_key,
error_text: None, conflict_mode,
operations: &operations,
pre_skipped: &skipped,
finished_at: &finished_at, finished_at: &finished_at,
}) })
.await?; .await?;
Ok(openapi_import_response(applied))
}
}
fn canonical_external_document_uri(base: Option<&str>, reference: &str) -> Option<String> {
if reference.starts_with('#') {
return None;
}
let mut url = match base {
Some(base) => url::Url::parse(base).ok()?.join(reference).ok()?,
None => url::Url::parse(reference).ok()?,
};
if !matches!(url.scheme(), "http" | "https")
|| !url.username().is_empty()
|| url.password().is_some()
{
return None;
}
url.set_fragment(None);
Some(url.to_string())
}
fn openapi_import_response(applied: ImportJobApplyResult) -> OpenApiImportCreateResponse {
let created = applied
.created
.iter()
.map(|operation| OpenApiImportCreatedOperation {
operation_key: operation.operation_key.clone(),
operation_id: operation.operation_id.as_str().to_owned(),
name: operation.name.clone(),
version: operation.version,
})
.collect::<Vec<_>>();
let mut findings = applied
.created
.iter()
.filter_map(|operation| {
operation
.renamed_from
.as_ref()
.map(|previous_name| ImportFinding {
code: "operation_name_renamed".to_owned(),
severity: ImportFindingSeverity::Info,
message: format!(
"Операция {previous_name} уже существует, новый черновик создан как {}.",
operation.name
),
operation_key: Some(operation.operation_key.clone()),
})
})
.collect::<Vec<_>>();
let skipped = applied
.skipped
.into_iter()
.map(|operation| {
let reason = operation.reason.clone();
let name = operation.name.clone();
findings.push(ImportFinding {
code: reason.clone(),
severity: ImportFindingSeverity::Warning,
message: if name.is_empty() {
"Выбранная операция отсутствует в исходном preview и была пропущена.".to_owned()
} else {
format!("Операция {name} уже существует и была пропущена.")
},
operation_key: Some(operation.operation_key.clone()),
});
OpenApiImportSkippedOperation {
operation_key: operation.operation_key.clone(),
name: operation.name,
reason,
}
})
.collect::<Vec<_>>();
info!( info!(
name: "admin.openapi_import.completed",
created = created.len(), created = created.len(),
skipped = skipped.len(), skipped = skipped.len(),
"openapi import created drafts" "openapi import created drafts"
); );
OpenApiImportCreateResponse {
Ok(OpenApiImportCreateResponse {
created, created,
skipped, skipped,
findings, findings,
}
}
fn validate_openapi_upload(upload: &OpenApiUpload) -> Result<(), ApiError> {
if upload.bytes.is_empty() {
return Err(ApiError::openapi_upload(upload.locale, "empty_file"));
}
if upload.bytes.len() > MAX_ARTIFACT_BYTES {
return Err(ApiError::openapi_upload(upload.locale, "file_too_large"));
}
if !matches!(
upload.mime_type.as_str(),
"application/yaml"
| "application/x-yaml"
| "text/yaml"
| "text/x-yaml"
| "application/json"
| "application/openapi+json"
| "application/octet-stream"
) {
return Err(ApiError::openapi_upload(
upload.locale,
"invalid_media_type",
));
}
if std::str::from_utf8(&upload.bytes).is_err() {
return Err(ApiError::openapi_upload(upload.locale, "invalid_utf8"));
}
Ok(())
}
struct ParsedOpenApiPreview {
preview: crank_import::rest::ImportPreview,
ir_fingerprint: String,
}
enum ImportReplayContract {
LegacyV1,
PersistedV2(ParsedOpenApiPreview),
Current,
}
fn import_replay_contract(
payload: &serde_json::Value,
locale: OpenApiUploadLocale,
) -> Result<ImportReplayContract, ApiError> {
let Some(normalization) = payload.get("normalization") else {
return Ok(ImportReplayContract::LegacyV1);
};
let normalizer = normalization
.get("normalizer_version")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
let projection = normalization
.get("projection_version")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
let ir_fingerprint = normalization
.get("ir_fingerprint")
.and_then(serde_json::Value::as_str)
.filter(|value| is_lower_sha256(value))
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
let expected_preview_digest = payload
.get("preview_digest")
.and_then(serde_json::Value::as_str)
.filter(|value| is_lower_sha256(value))
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
if normalizer == NORMALIZER_VERSION && projection == PROJECTION_VERSION {
return Ok(ImportReplayContract::Current);
}
if normalizer != "normalized-ir-v2" || projection != "preview-v2" {
return Err(ApiError::openapi_upload(locale, "source_integrity"));
}
let preview_value = payload
.get("preview")
.cloned()
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
if preview_digest(&preview_value)? != expected_preview_digest {
return Err(ApiError::openapi_upload(locale, "source_integrity"));
}
let preview = serde_json::from_value(preview_value)
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
Ok(ImportReplayContract::PersistedV2(ParsedOpenApiPreview {
preview,
ir_fingerprint: ir_fingerprint.to_owned(),
}))
}
fn is_lower_sha256(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
async fn parse_verified_preview(
bytes: Vec<u8>,
digest: String,
snapshots: Vec<crank_import::rest::ExternalDocumentSnapshot>,
normalization_config: crank_import::rest::NormalizationConfig,
locale: OpenApiUploadLocale,
legacy_v1: bool,
) -> Result<ParsedOpenApiPreview, ApiError> {
let started = tokio::time::Instant::now();
let permit = tokio::time::timeout(OPENAPI_PARSE_DEADLINE, OPENAPI_PARSE_SLOTS.acquire())
.await
.map_err(|_| ApiError::openapi_upload(locale, "parser_unavailable"))?
.map_err(|_| ApiError::openapi_upload(locale, "parser_unavailable"))?;
let parsing = tokio::task::spawn_blocking(move || {
// Keep the permit inside the blocking task. Timing out the caller
// cannot cancel CPU work already running, but abandoned parsers remain
// globally bounded and release capacity when they actually finish.
let _permit = permit;
let document = std::str::from_utf8(&bytes)
.map_err(|_| ApiError::openapi_upload(locale, "invalid_utf8"))?;
if legacy_v1 {
return crank_import::rest::preview_document_legacy_v1(document)
.map(|preview| ParsedOpenApiPreview {
preview,
ir_fingerprint: String::new(),
}) })
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"));
} }
let digest = crank_import::rest::SourceDigest::parse(digest)
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
let ir = crank_import::rest::normalize_verified_bundle(
document,
digest,
&snapshots,
&normalization_config,
)
.map_err(|error| match error {
crank_import::rest::ImportParseError::NoMethods => {
ApiError::openapi_upload(locale, "no_methods")
}
_ => ApiError::openapi_upload(locale, "invalid_document"),
})?;
let ir_fingerprint = preview_digest(
&serde_json::to_value(&ir)
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"))?,
)?;
Ok::<_, ApiError>(ParsedOpenApiPreview {
preview: crank_import::rest::preview_from_ir(&ir),
ir_fingerprint,
})
});
let remaining = OPENAPI_PARSE_DEADLINE
.checked_sub(started.elapsed())
.unwrap_or(std::time::Duration::ZERO);
tokio::time::timeout(remaining, parsing)
.await
.map_err(|_| ApiError::openapi_upload(locale, "parser_unavailable"))?
.map_err(|_| ApiError::openapi_upload(locale, "parser_unavailable"))?
}
async fn next_available_operation_name( fn artifact_error(locale: OpenApiUploadLocale, error: ArtifactError) -> ApiError {
&self, match error {
workspace_id: &WorkspaceId, ArtifactError::EmptySource => ApiError::openapi_upload(locale, "empty_file"),
base_name: &str, ArtifactError::SourceTooLarge => ApiError::openapi_upload(locale, "file_too_large"),
) -> Result<String, ApiError> { ArtifactError::Integrity => ApiError::openapi_upload(locale, "source_integrity"),
for index in 2.. { ArtifactError::Storage
let candidate = format!("{base_name}_{index}"); | ArtifactError::NotFound
if self | ArtifactError::InvalidReference
.find_operation_by_name(workspace_id, &candidate) | ArtifactError::UnsafeRoot => ApiError::openapi_upload(locale, "storage_unavailable"),
.await? }
.is_none() }
fn openapi_source_error(locale: OpenApiUploadLocale, error: RegistryError) -> ApiError {
match error {
RegistryError::SourceNotFound { .. } | RegistryError::SourceUnavailable => {
ApiError::openapi_upload(locale, "source_unavailable")
}
RegistryError::SourceIntegrity => ApiError::openapi_upload(locale, "source_integrity"),
other => ApiError::from(other),
}
}
fn preview_digest(preview: &serde_json::Value) -> Result<String, ApiError> {
let canonical =
serde_json::to_vec(preview).map_err(|error| ApiError::internal(error.to_string()))?;
Ok(format!("{:x}", Sha256::digest(canonical)))
}
fn verify_preview_contract(
payload: &serde_json::Value,
parsed: &ParsedOpenApiPreview,
locale: OpenApiUploadLocale,
) -> Result<(), ApiError> {
// Pre-fingerprint jobs are legacy rolling-upgrade records. They retain the
// old reparse behavior; new jobs fail closed if parser output drifts or a
// persisted preview has been changed.
let expected = payload
.get("preview_digest")
.and_then(serde_json::Value::as_str);
if payload.get("normalization").is_some() && expected.is_none() {
return Err(ApiError::openapi_upload(locale, "source_integrity"));
}
let Some(expected) = expected else {
return Ok(());
};
let actual = preview_digest(
&serde_json::to_value(&parsed.preview)
.map_err(|error| ApiError::internal(error.to_string()))?,
)?;
if actual == expected {
if let Some(normalization) = payload.get("normalization") {
let normalizer = normalization
.get("normalizer_version")
.and_then(serde_json::Value::as_str);
let projection = normalization
.get("projection_version")
.and_then(serde_json::Value::as_str);
let fingerprint = normalization
.get("ir_fingerprint")
.and_then(serde_json::Value::as_str);
if normalizer != Some(NORMALIZER_VERSION)
|| projection != Some(PROJECTION_VERSION)
|| fingerprint != Some(parsed.ir_fingerprint.as_str())
{ {
return Ok(candidate); return Err(ApiError::openapi_upload(locale, "source_integrity"));
} }
} }
Ok(())
} else {
Err(ApiError::openapi_upload(locale, "source_integrity"))
}
}
unreachable!() fn preview_has_blocker(
} preview: &crank_import::rest::ImportPreview,
selected_operation_keys: &BTreeSet<String>,
) -> bool {
preview
.findings
.iter()
.any(|finding| finding.severity == ImportFindingSeverity::Error)
|| preview
.groups
.iter()
.flat_map(|group| group.operations.iter())
.filter(|operation| selected_operation_keys.contains(&operation.key))
.flat_map(|operation| operation.findings.iter())
.any(|finding| finding.severity == ImportFindingSeverity::Error)
}
fn openapi_application_key(payload: &OpenApiImportCreatePayload) -> Result<String, ApiError> {
let selected_operation_keys = payload
.selected_operation_keys
.iter()
.cloned()
.collect::<BTreeSet<_>>();
let canonical = serde_json::to_vec(&json!({
"selected_operation_keys": selected_operation_keys,
"server_url": payload.server_url.as_deref(),
"conflict_mode": payload.conflict_mode.as_str(),
}))
.map_err(|error| ApiError::internal(error.to_string()))?;
Ok(format!("{:x}", Sha256::digest(canonical)))
} }
fn attach_import_findings( fn attach_import_findings(
@@ -0,0 +1,405 @@
use super::*;
use std::collections::VecDeque;
use tracing::warn;
pub(super) struct MaterializedDependencies {
pub(super) snapshots: Vec<crank_import::rest::ExternalDocumentSnapshot>,
envelopes: Vec<ImportJobSourceEnvelope>,
canonical_uris: Vec<String>,
guards: Vec<SourceDetachGuard>,
}
pub(super) struct ImportReplayContext<'a> {
pub(super) workspace_id: &'a WorkspaceId,
pub(super) job_id: &'a ImportJobId,
pub(super) application_key: &'a str,
pub(super) conflict_mode: ImportConflictMode,
pub(super) finished_at: &'a OffsetDateTime,
}
impl MaterializedDependencies {
fn empty() -> Self {
Self {
snapshots: Vec::new(),
envelopes: Vec::new(),
canonical_uris: Vec::new(),
guards: Vec::new(),
}
}
pub(super) fn payload(&self) -> Vec<serde_json::Value> {
self.envelopes
.iter()
.zip(&self.canonical_uris)
.map(|(dependency, canonical_uri)| {
json!({
"source_id": dependency.source_id.as_str(),
"digest": dependency.digest.as_str(),
"canonical_uri": canonical_uri,
})
})
.collect()
}
pub(super) fn snapshot_payload(&self) -> Vec<serde_json::Value> {
self.snapshots
.iter()
.filter_map(|snapshot| {
self.envelopes
.iter()
.find(|dependency| dependency.digest.digest_hex() == snapshot.digest.as_str())
.map(|dependency| {
json!({
"source_id": dependency.source_id.as_str(),
"digest": dependency.digest.as_str(),
"canonical_uri": snapshot.canonical_uri,
})
})
})
.collect()
}
pub(super) fn disarm(&mut self) {
for guard in &mut self.guards {
guard.disarm();
}
}
pub(super) async fn detach_all(&mut self) {
for guard in &mut self.guards {
guard.detach_now().await;
}
}
}
impl AdminService {
pub(super) async fn fail_openapi_import_or_replay(
&self,
context: &ImportReplayContext<'_>,
error_text: &'static str,
error: ApiError,
) -> Result<OpenApiImportCreateResponse, ApiError> {
let empty = json!([]);
self.registry
.finish_import_job(FinishImportJobRequest {
id: context.job_id,
status: ImportJobStatus::Failed,
created_operation_ids: &empty,
error_text: Some(error_text),
finished_at: context.finished_at,
})
.await?;
// `finish_import_job` preserves an already Completed row under its
// lock. This read distinguishes that race from the terminal Failed
// transition and returns the immutable canonical application result.
let latest = self
.registry
.get_import_job(context.workspace_id, context.job_id)
.await?;
if latest.is_some_and(|job| job.status == ImportJobStatus::Completed) {
let applied = self
.registry
.apply_import_job(ApplyImportJobRequest {
id: context.job_id,
workspace_id: context.workspace_id,
application_key: context.application_key,
conflict_mode: context.conflict_mode,
operations: &[],
pre_skipped: &[],
finished_at: context.finished_at,
})
.await?;
return Ok(openapi_import_response(applied));
}
Err(error)
}
pub(super) async fn materialize_external_reference_snapshots(
&self,
workspace_id: &WorkspaceId,
primary_bytes: &[u8],
created_at: OffsetDateTime,
) -> MaterializedDependencies {
let materialized_count = std::sync::atomic::AtomicUsize::new(0);
match tokio::time::timeout(
self.external_reference_materialization_timeout,
self.materialize_external_reference_snapshots_inner(
workspace_id,
primary_bytes,
created_at,
&materialized_count,
),
)
.await
{
Ok(dependencies) => dependencies,
Err(_) => {
materialization_failure(
"chain",
"timeout",
materialized_count.load(std::sync::atomic::Ordering::Relaxed),
);
MaterializedDependencies::empty()
}
}
}
async fn materialize_external_reference_snapshots_inner(
&self,
workspace_id: &WorkspaceId,
primary_bytes: &[u8],
created_at: OffsetDateTime,
materialized_count: &std::sync::atomic::AtomicUsize,
) -> MaterializedDependencies {
if !self
.external_reference_normalization
.external_references_enabled
{
return MaterializedDependencies::empty();
}
let Ok(primary) = std::str::from_utf8(primary_bytes) else {
materialization_failure("primary_decode", "invalid_utf8", 0);
return MaterializedDependencies::empty();
};
let Ok(primary_references) =
crank_import::rest::reference_uris(primary, &self.external_reference_normalization)
else {
materialization_failure("primary_scan", "invalid_document", 0);
return MaterializedDependencies::empty();
};
let mut queue = VecDeque::new();
for reference in primary_references {
if let Some(uri) = canonical_external_document_uri(None, &reference) {
queue.push_back((uri, 1usize));
}
}
let mut seen = BTreeSet::new();
let mut result = MaterializedDependencies::empty();
let mut dependency_by_digest = BTreeMap::<String, usize>::new();
while let Some((canonical_uri, depth)) = queue.pop_front() {
if !seen.insert(canonical_uri.clone())
|| depth > self.external_reference_normalization.max_reference_depth
|| seen.len()
> self
.external_reference_normalization
.max_reference_documents
{
continue;
}
let bytes = match self.external_reference_fetcher.get(&canonical_uri).await {
Ok(bytes) => bytes,
Err(error) => {
materialization_failure(
"fetch",
external_fetch_error_code(&error),
result.snapshots.len(),
);
continue;
}
};
let Ok(document) = std::str::from_utf8(&bytes).map(str::to_owned) else {
materialization_failure(
"dependency_decode",
"invalid_utf8",
result.snapshots.len(),
);
continue;
};
let Ok(references) = crank_import::rest::external_reference_uris(
&document,
&self.external_reference_normalization,
) else {
materialization_failure(
"dependency_scan",
"invalid_document",
result.snapshots.len(),
);
continue;
};
let store = self.artifact_store.clone();
let artifact_bytes = bytes.clone();
let Ok(Ok(artifact)) =
tokio::task::spawn_blocking(move || store.put_registered(&artifact_bytes)).await
else {
materialization_failure(
"artifact_store",
"storage_unavailable",
result.snapshots.len(),
);
continue;
};
let digest_key = artifact.artifact_ref().as_str().to_owned();
let envelope_index = if let Some(index) = dependency_by_digest.get(&digest_key) {
*index
} else {
let source_id = ArtifactSourceId::new(new_prefixed_id("src_openapi_dep"));
let Ok(source) = self
.registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id,
source_id: &source_id,
artifact: &artifact,
mime_type: "application/octet-stream",
sensitivity: ArtifactSourceSensitivity::Internal,
created_at,
})
.await
else {
materialization_failure(
"source_create",
"registry_unavailable",
result.snapshots.len(),
);
continue;
};
let mut guard = SourceDetachGuard::new(
self.registry.clone(),
workspace_id.clone(),
source_id.clone(),
source.updated_at,
);
let Ok(verified) = self
.registry
.read_artifact_source(
std::sync::Arc::clone(&self.artifact_store),
workspace_id,
&source_id,
)
.await
else {
materialization_failure(
"source_verify",
"source_unavailable",
result.snapshots.len(),
);
guard.detach_now().await;
continue;
};
if verified.source.blob.artifact_ref != *artifact.artifact_ref() {
materialization_failure(
"source_verify",
"source_integrity",
result.snapshots.len(),
);
guard.detach_now().await;
continue;
}
let index = result.envelopes.len();
result.envelopes.push(ImportJobSourceEnvelope {
source_id: source_id.clone(),
digest: artifact.artifact_ref().clone(),
});
result.guards.push(guard);
dependency_by_digest.insert(digest_key, index);
index
};
let dependency = &result.envelopes[envelope_index];
let snapshot_digest = match crank_import::rest::SourceDigest::parse(
dependency.digest.digest_hex().to_owned(),
) {
Ok(digest) => digest,
Err(_) => {
materialization_failure("snapshot", "source_integrity", result.snapshots.len());
continue;
}
};
result
.snapshots
.push(crank_import::rest::ExternalDocumentSnapshot {
canonical_uri: canonical_uri.clone(),
digest: snapshot_digest,
document,
});
materialized_count.store(result.snapshots.len(), std::sync::atomic::Ordering::Relaxed);
result.canonical_uris.push(canonical_uri.clone());
for reference in references {
if let Some(uri) = canonical_external_document_uri(Some(&canonical_uri), &reference)
{
queue.push_back((uri, depth.saturating_add(1)));
}
}
}
// Registry ownership is one envelope per immutable digest, while the
// resolver may map several canonical URIs to that same snapshot. Keep
// payload rows aligned with envelopes and add aliases separately.
if result.envelopes.len() != result.canonical_uris.len() {
let mut canonical_by_digest = BTreeMap::new();
for snapshot in &result.snapshots {
canonical_by_digest
.entry(snapshot.digest.as_str().to_owned())
.or_insert_with(|| snapshot.canonical_uri.clone());
}
result.canonical_uris = result
.envelopes
.iter()
.map(|envelope| {
canonical_by_digest
.get(envelope.digest.digest_hex())
.cloned()
.unwrap_or_default()
})
.collect();
}
result
}
pub(super) async fn read_import_job_dependencies(
&self,
workspace_id: &WorkspaceId,
payload: &serde_json::Value,
locale: OpenApiUploadLocale,
) -> Result<Vec<crank_import::rest::ExternalDocumentSnapshot>, ApiError> {
let dependencies = import_job_dependencies(payload, locale)?;
let mut snapshots = Vec::with_capacity(dependencies.len());
for (canonical_uri, dependency) in dependencies {
let verified = self
.registry
.read_artifact_source(
std::sync::Arc::clone(&self.artifact_store),
workspace_id,
&dependency.source_id,
)
.await
.map_err(|error| openapi_source_error(locale, error))?;
if verified.source.blob.artifact_ref != dependency.digest {
return Err(ApiError::openapi_upload(locale, "source_integrity"));
}
let document = String::from_utf8(verified.bytes)
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
let digest =
crank_import::rest::SourceDigest::parse(dependency.digest.digest_hex().to_owned())
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
snapshots.push(crank_import::rest::ExternalDocumentSnapshot {
canonical_uri,
digest,
document,
});
}
Ok(snapshots)
}
}
fn external_fetch_error_code(error: &crank_runtime::ExternalReferenceFetchError) -> &'static str {
match error {
crank_runtime::ExternalReferenceFetchError::Disabled => "disabled",
crank_runtime::ExternalReferenceFetchError::InvalidUrl => "invalid_url",
crank_runtime::ExternalReferenceFetchError::TargetNotAllowed => "target_not_allowed",
crank_runtime::ExternalReferenceFetchError::RedirectNotAllowed => "redirect_not_allowed",
crank_runtime::ExternalReferenceFetchError::ResponseTooLarge { .. } => "response_too_large",
crank_runtime::ExternalReferenceFetchError::UnexpectedStatus { .. } => "unexpected_status",
crank_runtime::ExternalReferenceFetchError::Transport { timeout: true, .. } => "timeout",
crank_runtime::ExternalReferenceFetchError::Transport { .. } => "transport",
crank_runtime::ExternalReferenceFetchError::InvalidConfiguration => "invalid_configuration",
}
}
fn materialization_failure(stage: &'static str, error_code: &'static str, count: usize) {
warn!(
name: "admin.openapi_import.materialization_failed",
stage,
error_code,
count,
"external OpenAPI materialization failed"
);
}
@@ -0,0 +1,169 @@
use std::collections::BTreeSet;
use crank_core::WorkspaceId;
use crank_registry::{ArtifactSourceId, DetachArtifactSourceRequest, ImportJobSourceEnvelope};
use time::OffsetDateTime;
use crate::{error::ApiError, service::OpenApiUploadLocale};
use super::canonical_external_document_uri;
const MAX_IMPORT_JOB_DOCUMENTS: usize = 32;
pub(super) fn import_job_source(
payload: &serde_json::Value,
locale: OpenApiUploadLocale,
) -> Result<ImportJobSourceEnvelope, ApiError> {
let source = payload
.get("source")
.ok_or_else(|| ApiError::openapi_upload(locale, "source_unavailable"))?;
let source_id = source
.get("source_id")
.and_then(serde_json::Value::as_str)
.filter(|value| value.len() <= 132)
.ok_or_else(|| ApiError::openapi_upload(locale, "source_unavailable"))?;
let digest = source
.get("digest")
.and_then(serde_json::Value::as_str)
.and_then(|value| value.parse().ok())
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
Ok(ImportJobSourceEnvelope {
source_id: ArtifactSourceId::new(source_id),
digest,
})
}
pub(super) fn import_job_dependencies(
payload: &serde_json::Value,
locale: OpenApiUploadLocale,
) -> Result<Vec<(String, ImportJobSourceEnvelope)>, ApiError> {
let empty = Vec::new();
let items = payload
.get("dependency_snapshots")
.and_then(serde_json::Value::as_array)
.unwrap_or(&empty);
if items.len() > MAX_IMPORT_JOB_DOCUMENTS {
return Err(ApiError::openapi_upload(locale, "source_integrity"));
}
let mut canonical_uris = BTreeSet::new();
items
.iter()
.map(|item| {
let canonical_uri = item
.get("canonical_uri")
.and_then(serde_json::Value::as_str)
.filter(|value| {
canonical_external_document_uri(None, value).as_deref() == Some(*value)
})
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
if !canonical_uris.insert(canonical_uri.to_owned()) {
return Err(ApiError::openapi_upload(locale, "source_integrity"));
}
let source_id = item
.get("source_id")
.and_then(serde_json::Value::as_str)
.filter(|value| value.len() <= 132)
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
let digest = item
.get("digest")
.and_then(serde_json::Value::as_str)
.and_then(|value| value.parse().ok())
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
Ok((
canonical_uri.to_owned(),
ImportJobSourceEnvelope {
source_id: ArtifactSourceId::new(source_id),
digest,
},
))
})
.collect()
}
pub(super) fn import_job_normalization_config(
payload: &serde_json::Value,
current: &crank_import::rest::NormalizationConfig,
legacy_v1: bool,
locale: OpenApiUploadLocale,
) -> Result<crank_import::rest::NormalizationConfig, ApiError> {
if legacy_v1 {
return Ok(current.clone());
}
payload
.pointer("/normalization/config")
.cloned()
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))
.and_then(|value| {
serde_json::from_value(value)
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))
})
}
pub(super) struct SourceDetachGuard {
registry: crank_registry::PostgresRegistry,
workspace_id: WorkspaceId,
source_id: ArtifactSourceId,
expected_updated_at: OffsetDateTime,
armed: bool,
}
impl SourceDetachGuard {
pub(super) fn new(
registry: crank_registry::PostgresRegistry,
workspace_id: WorkspaceId,
source_id: ArtifactSourceId,
expected_updated_at: OffsetDateTime,
) -> Self {
Self {
registry,
workspace_id,
source_id,
expected_updated_at,
armed: true,
}
}
pub(super) fn disarm(&mut self) {
self.armed = false;
}
pub(super) async fn detach_now(&mut self) {
if !self.armed {
return;
}
self.armed = false;
let _ = self
.registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &self.workspace_id,
source_id: &self.source_id,
expected_updated_at: Some(self.expected_updated_at),
detached_at: OffsetDateTime::now_utc(),
})
.await;
}
}
impl Drop for SourceDetachGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let registry = self.registry.clone();
let workspace_id = self.workspace_id.clone();
let source_id = self.source_id.clone();
let expected_updated_at = Some(self.expected_updated_at);
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let _ = registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &workspace_id,
source_id: &source_id,
expected_updated_at,
detached_at: OffsetDateTime::now_utc(),
})
.await;
});
}
}
}
+475 -20
View File
@@ -1,48 +1,102 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{ use crank_core::{
AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod, AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod,
WorkspaceId, WorkspaceId,
}; };
use crank_registry::{ use crank_registry::{
ApprovalRequestRecord, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery, ApprovalRequestRecord, DecideApprovalRequest, ExpireApprovalRequest, InvocationLogRecord,
ListInvocationLogsQuery, UsageQuery, UsageRollupRecord, InvocationRetentionOutcome, ListApprovalRequestsQuery, ListInvocationLogsQuery, UsageQuery,
UsageRollupRecord,
}; };
use serde_json::json; use serde_json::json;
use time::OffsetDateTime; use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::instrument; use tracing::instrument;
use crate::{ use crate::{
error::ApiError, error::ApiError,
service::{ service::{
AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery, AdminService, ApprovalDecisionPayload, ApprovalsQuery, LogsListResponse, LogsQuery,
usage_window, 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 { impl AdminService {
pub async fn cleanup_invocation_logs_before(
&self,
cutoff: OffsetDateTime,
) -> Result<InvocationRetentionOutcome, ApiError> {
self.registry
.delete_invocation_logs_before(cutoff)
.await
.map_err(ApiError::from)
}
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn list_logs( pub async fn list_logs(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
query: LogsQuery, query: LogsQuery,
) -> Result<Vec<InvocationLogRecord>, ApiError> { ) -> Result<LogsListResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
let operation_id = query.operation_id.as_deref().map(OperationId::new); let operation_id = query.operation_id.as_deref().map(OperationId::new);
let agent_id = query.agent_id.as_deref().map(AgentId::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 { .list_invocation_logs(ListInvocationLogsQuery {
workspace_id, workspace_id,
level: query.level, level: query.level,
status: query.status,
outcome_group: query.outcome_group,
search_text: query.search.as_deref(), search_text: query.search.as_deref(),
source: query.source, source: query.source,
operation_id: operation_id.as_ref(), operation_id: operation_id.as_ref(),
agent_id: agent_id.as_ref(), agent_id: agent_id.as_ref(),
created_after: Some(&created_after), 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 .await?;
.map_err(ApiError::from)
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))] #[instrument(skip(self))]
@@ -62,6 +116,69 @@ impl AdminService {
}) })
} }
#[instrument(skip(self))]
pub async fn export_logs_csv(
&self,
workspace_id: &WorkspaceId,
mut query: LogsQuery,
) -> Result<String, ApiError> {
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::<Vec<_>>().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))] #[instrument(skip(self))]
pub async fn list_approvals( pub async fn list_approvals(
&self, &self,
@@ -82,7 +199,7 @@ impl AdminService {
for record in records { for record in records {
let record = self.normalize_approval_record(record).await?; let record = self.normalize_approval_record(record).await?;
if query.status.is_none() || record.approval.status == query.status.unwrap() { if query.status.is_none() || record.approval.status == query.status.unwrap() {
normalized.push(record); normalized.push(safe_approval_record(record));
} }
} }
@@ -107,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<ApprovalRequestRecord, ApiError> {
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<ApprovalRequestRecord, ApiError> {
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<serde_json::Value>,
decision_note: Option<&str>,
) -> Result<ApprovalRequestRecord, ApiError> {
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( async fn normalize_approval_record(
@@ -139,13 +363,14 @@ impl AdminService {
query: UsageRequestQuery, query: UsageRequestQuery,
) -> Result<UsageOverviewResponse, ApiError> { ) -> Result<UsageOverviewResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
let (period, created_after, bucket) = let (period, created_after, created_before, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?; resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
let usage_query = UsageQuery { let usage_query = UsageQuery {
workspace_id, workspace_id,
period, period,
source: query.source, source: query.source,
created_after: &created_after, created_after: &created_after,
created_before: &created_before,
bucket, bucket,
}; };
@@ -158,16 +383,104 @@ impl AdminService {
.registry .registry
.list_usage_by_operation(usage_query.clone()) .list_usage_by_operation(usage_query.clone())
.await?; .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 { Ok(UsageOverviewResponse {
summary, summary,
timeline, timeline,
operations, operations,
agents, agents,
outcomes,
}) })
} }
#[instrument(skip(self))]
pub async fn export_usage_csv(
&self,
workspace_id: &WorkspaceId,
query: UsageRequestQuery,
) -> Result<String, ApiError> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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))] #[instrument(skip(self))]
pub async fn get_operation_usage( pub async fn get_operation_usage(
&self, &self,
@@ -176,8 +489,8 @@ impl AdminService {
query: UsageRequestQuery, query: UsageRequestQuery,
) -> Result<UsageRollupRecord, ApiError> { ) -> Result<UsageRollupRecord, ApiError> {
self.get_operation(workspace_id, operation_id).await?; self.get_operation(workspace_id, operation_id).await?;
let (period, created_after, bucket) = let (period, created_after, created_before, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?; resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
self.registry self.registry
.get_usage_for_operation( .get_usage_for_operation(
@@ -186,6 +499,7 @@ impl AdminService {
period, period,
source: query.source, source: query.source,
created_after: &created_after, created_after: &created_after,
created_before: &created_before,
bucket, bucket,
}, },
operation_id, operation_id,
@@ -210,8 +524,8 @@ impl AdminService {
query: UsageRequestQuery, query: UsageRequestQuery,
) -> Result<UsageRollupRecord, ApiError> { ) -> Result<UsageRollupRecord, ApiError> {
self.get_agent(workspace_id, agent_id).await?; self.get_agent(workspace_id, agent_id).await?;
let (period, created_after, bucket) = let (period, created_after, created_before, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?; resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
self.registry self.registry
.get_usage_for_agent( .get_usage_for_agent(
@@ -220,6 +534,7 @@ impl AdminService {
period, period,
source: query.source, source: query.source,
created_after: &created_after, created_after: &created_after,
created_before: &created_before,
bucket, bucket,
}, },
agent_id, agent_id,
@@ -233,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<String>, Option<InvocationLogId>), 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<String, ApiError> {
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<T: serde::Serialize>(value: T) -> Result<String, ApiError> {
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('"', "\"\""))
}
+268
View File
@@ -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<OnboardingResponse, ApiError> {
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<OnboardingEventResponse, ApiError> {
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<ResetOnboardingSelectionResponse, ApiError> {
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<OnboardingResponse, ApiError> {
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"}),
)
}
@@ -3,6 +3,8 @@ use serde_json::json;
use crate::error::ApiError; use crate::error::ApiError;
const MAX_OPERATION_TIMEOUT_MS: u64 = 300_000;
pub(super) fn validate_protocol_target( pub(super) fn validate_protocol_target(
protocol: Protocol, protocol: Protocol,
target: &Target, target: &Target,
@@ -16,6 +18,18 @@ pub(super) fn validate_protocol_target(
Err(ApiError::validation("protocol and target kind must match")) Err(ApiError::validation("protocol and target kind must match"))
} }
pub(super) fn validate_execution_timeout(
execution_config: &crank_core::ExecutionConfig,
) -> Result<(), ApiError> {
if !(1..=MAX_OPERATION_TIMEOUT_MS).contains(&execution_config.timeout_ms) {
return Err(ApiError::validation_with_context(
format!("operation timeout must be between 1 and {MAX_OPERATION_TIMEOUT_MS} ms"),
json!({ "field": "execution_config.timeout_ms" }),
));
}
Ok(())
}
pub(super) fn validate_response_cache_policy( pub(super) fn validate_response_cache_policy(
target: &Target, target: &Target,
execution_config: &crank_core::ExecutionConfig, execution_config: &crank_core::ExecutionConfig,
@@ -150,7 +164,8 @@ mod tests {
}; };
use super::{ use super::{
validate_approval_policy, validate_idempotency_policy, validate_response_cache_policy, validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
validate_response_cache_policy,
}; };
fn cacheable_execution_config() -> ExecutionConfig { fn cacheable_execution_config() -> ExecutionConfig {
@@ -198,6 +213,19 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test]
fn rejects_zero_and_excessive_execution_timeouts() {
let mut config = cacheable_execution_config();
config.timeout_ms = 0;
assert!(validate_execution_timeout(&config).is_err());
config.timeout_ms = 300_001;
assert!(validate_execution_timeout(&config).is_err());
config.timeout_ms = 300_000;
assert!(validate_execution_timeout(&config).is_ok());
}
#[test] #[test]
fn rejects_response_cache_for_non_get_rest_operation() { fn rejects_response_cache_for_non_get_rest_operation() {
let target = Target::Rest(RestTarget { let target = Target::Rest(RestTarget {
+286 -102
View File
@@ -1,14 +1,17 @@
use crank_core::{ use crank_core::{
ConfigExport, ExecutionMode, ExportMode, InvocationLevel, InvocationSource, InvocationStatus, ConfigExport, ExecutionMode, ExecutionOrigin, ExportMode, InvocationLevel, InvocationSource,
OperationId, OperationStatus, Samples, WorkspaceId, InvocationStatus, OperationAvailability, OperationId, OperationStatus, OperationVersionState,
Samples, WorkspaceId,
}; };
use crank_registry::{ use crank_registry::{
CreateVersionRequest, OperationVersionRecord, PublishRequest, RegistryOperation, CreateVersionRequest, OperationStateExpectation, OperationVersionRecord, PublishRequest,
RegistryOperation,
}; };
use crank_runtime::{ use crank_runtime::{
RuntimeError, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext, ExecutionAuthorization, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext,
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use time::OffsetDateTime; use time::OffsetDateTime;
use tracing::{info, instrument}; use tracing::{info, instrument};
@@ -18,13 +21,33 @@ use crate::{
AdminService, CreatedOperationResponse, InvocationRecordRequest, NewVersionPayload, AdminService, CreatedOperationResponse, InvocationRecordRequest, NewVersionPayload,
OperationDetailView, OperationMutationResult, OperationPayload, OperationSummaryView, OperationDetailView, OperationMutationResult, OperationPayload, OperationSummaryView,
PublishResponse, TestRunPayload, TestRunResult, UpdateOperationPayload, VersionRef, PublishResponse, TestRunPayload, TestRunResult, UpdateOperationPayload, VersionRef,
agent_ref_map, build_request_preview, default_usage_summary, enrich_operation_summary, agent_ref_map, default_usage_summary, enrich_operation_summary, format_timestamp,
format_timestamp, new_prefixed_id, now_string, runtime_error_code, today_start_utc, new_prefixed_id, now_string, today_start_utc, tool_quality_mapping_set,
tool_quality_mapping_set, tool_quality_schema_node, usage_map, tool_quality_schema_node, usage_map,
}, },
}; };
impl AdminService { 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<String, ApiError> {
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( pub async fn list_operations(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
@@ -84,6 +107,11 @@ impl AdminService {
let refs = agent_ref_map(agent_refs) let refs = agent_ref_map(agent_refs)
.remove(operation_id.as_str()) .remove(operation_id.as_str())
.unwrap_or_default(); .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 { Ok(OperationDetailView {
id: summary.id.as_str().to_owned(), id: summary.id.as_str().to_owned(),
@@ -94,6 +122,11 @@ impl AdminService {
protocol: summary.protocol, protocol: summary.protocol,
security_level: summary.security_level, security_level: summary.security_level,
status: summary.status, status: summary.status,
availability: if summary.status == OperationStatus::Archived {
OperationAvailability::Archived
} else {
OperationAvailability::Active
},
current_draft_version: summary.current_draft_version, current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version, latest_published_version: summary.latest_published_version,
created_at: format_timestamp(summary.created_at), created_at: format_timestamp(summary.created_at),
@@ -101,11 +134,15 @@ impl AdminService {
published_at: summary.published_at.map(format_timestamp), published_at: summary.published_at.map(format_timestamp),
draft_version_ref: VersionRef { draft_version_ref: VersionRef {
version: summary.current_draft_version, 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 { published_version_ref: summary.latest_published_version.map(|version| VersionRef {
version, version,
status: OperationStatus::Published, status: OperationVersionState::Published,
}), }),
agent_refs: refs, agent_refs: refs,
}) })
@@ -141,7 +178,6 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
payload: OperationPayload, payload: OperationPayload,
) -> Result<CreatedOperationResponse, ApiError> { ) -> Result<CreatedOperationResponse, ApiError> {
self.validate_operation_payload(&payload)?;
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
if self if self
@@ -155,10 +191,38 @@ 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
.create_operation(workspace_id, &snapshot, None)
.await?;
info!(
name: "admin.operation.created",
operation_id = %operation_id.as_str(),
version = 1,
"operation created"
);
Ok(CreatedOperationResponse {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: 1,
status: OperationStatus::Draft,
updated_at: format_timestamp(snapshot.updated_at),
})
}
pub(super) fn new_operation_snapshot(
&self,
payload: OperationPayload,
) -> Result<RegistryOperation, ApiError> {
self.validate_operation_payload(&payload)?;
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let operation_id = OperationId::new(new_prefixed_id("op")); Ok(RegistryOperation {
let snapshot = RegistryOperation { id: OperationId::new(new_prefixed_id("op")),
id: operation_id.clone(),
name: payload.name, name: payload.name,
display_name: payload.display_name, display_name: payload.display_name,
category: payload.category, category: payload.category,
@@ -183,19 +247,6 @@ impl AdminService {
created_at: now, created_at: now,
updated_at: now, updated_at: now,
published_at: None, published_at: None,
};
self.registry
.create_operation(workspace_id, &snapshot, None)
.await?;
info!(operation_id = %operation_id.as_str(), version = 1, "operation created");
Ok(CreatedOperationResponse {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: 1,
status: OperationStatus::Draft,
updated_at: format_timestamp(snapshot.updated_at),
}) })
} }
@@ -228,6 +279,7 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
operation_id: &OperationId, operation_id: &OperationId,
payload: NewVersionPayload, payload: NewVersionPayload,
expected_state: &OperationStateExpectation,
) -> Result<CreatedOperationResponse, ApiError> { ) -> Result<CreatedOperationResponse, ApiError> {
self.validate_operation_payload(&payload.operation)?; self.validate_operation_payload(&payload.operation)?;
@@ -242,7 +294,27 @@ impl AdminService {
) )
})?; })?;
let now = OffsetDateTime::now_utc(); 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 { let snapshot = RegistryOperation {
id: operation_id.clone(), id: operation_id.clone(),
name: payload.operation.name, name: payload.operation.name,
@@ -271,15 +343,26 @@ impl AdminService {
published_at: None, published_at: None,
}; };
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
.await?;
self.registry self.registry
.create_version(CreateVersionRequest { .create_version_cas(
CreateVersionRequest {
workspace_id, workspace_id,
snapshot: &snapshot, snapshot: &snapshot,
change_note: payload.change_note.as_deref(), change_note: payload.change_note.as_deref(),
created_by: None, created_by: None,
}) },
expected_state,
)
.await?; .await?;
info!(operation_id = %operation_id.as_str(), version, "operation version created"); info!(
name: "admin.operation.version_created",
operation_id = %operation_id.as_str(),
version,
"operation version created"
);
Ok(CreatedOperationResponse { Ok(CreatedOperationResponse {
operation_id: operation_id.as_str().to_owned(), operation_id: operation_id.as_str().to_owned(),
@@ -296,21 +379,20 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
operation_id: &OperationId, operation_id: &OperationId,
payload: UpdateOperationPayload, payload: UpdateOperationPayload,
expected_state: &OperationStateExpectation,
) -> Result<OperationMutationResult, ApiError> { ) -> Result<OperationMutationResult, ApiError> {
let existing = self let existing = self
.get_operation_version( .get_operation_version(
workspace_id, workspace_id,
operation_id, operation_id,
self.get_operation(workspace_id, operation_id) expected_state.current_draft_version,
.await?
.current_draft_version,
) )
.await?; .await?;
let updated_at = OffsetDateTime::now_utc(); let updated_at = OffsetDateTime::now_utc();
let snapshot = RegistryOperation { let snapshot = RegistryOperation {
id: operation_id.clone(), id: operation_id.clone(),
name: existing.snapshot.name, name: existing.snapshot.name.clone(),
display_name: payload.display_name, display_name: payload.display_name,
category: payload.category, category: payload.category,
protocol: existing.snapshot.protocol, protocol: existing.snapshot.protocol,
@@ -324,24 +406,39 @@ impl AdminService {
output_mapping: payload.output_mapping, output_mapping: payload.output_mapping,
execution_config: payload.execution_config, execution_config: payload.execution_config,
tool_description: payload.tool_description, tool_description: payload.tool_description,
samples: existing.snapshot.samples, samples: existing.snapshot.samples.clone(),
generated_draft: existing.snapshot.generated_draft, generated_draft: existing.snapshot.generated_draft.clone(),
config_export: existing.snapshot.config_export, config_export: existing.snapshot.config_export.clone(),
wizard_state: payload.wizard_state, wizard_state: payload.wizard_state,
created_at: existing.snapshot.created_at, created_at: existing.snapshot.created_at,
updated_at, updated_at,
published_at: existing.snapshot.published_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 self.registry
.update_operation_draft(workspace_id, &snapshot) .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_cas(workspace_id, &snapshot, expected_state)
.await?; .await?;
Ok(OperationMutationResult { Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(), operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_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, status: snapshot.status,
updated_at: format_timestamp(updated_at), updated_at: format_timestamp(updated_at),
}) })
@@ -353,24 +450,73 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
operation_id: &OperationId, operation_id: &OperationId,
version: u32, version: u32,
expected_state: &OperationStateExpectation,
) -> Result<PublishResponse, ApiError> { ) -> Result<PublishResponse, ApiError> {
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(); let published_at = OffsetDateTime::now_utc();
self.registry self.registry
.publish_operation(PublishRequest { .publish_operation_cas(
PublishRequest {
workspace_id, workspace_id,
operation_id, operation_id,
version, version,
published_at: &published_at, published_at: &published_at,
published_by: None, published_by: None,
}) },
expected_state,
)
.await?; .await?;
info!(operation_id = %operation_id.as_str(), version, "operation published"); let authoritative = self
.get_operation_version(workspace_id, operation_id, version)
.await?;
info!(
name: "admin.operation.published",
operation_id = %operation_id.as_str(),
version,
"operation published"
);
Ok(PublishResponse { Ok(PublishResponse {
operation_id: operation_id.as_str().to_owned(), operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(),
published_version: version, 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)),
}) })
} }
@@ -379,19 +525,21 @@ impl AdminService {
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
operation_id: &OperationId, operation_id: &OperationId,
expected_state: &OperationStateExpectation,
) -> Result<OperationMutationResult, ApiError> { ) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?; let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = OffsetDateTime::now_utc(); let updated_at = OffsetDateTime::now_utc();
self.registry self.registry
.archive_operation(workspace_id, operation_id, &updated_at) .archive_operation_cas(workspace_id, operation_id, &updated_at, expected_state)
.await?; .await?;
let authoritative = self.get_operation(workspace_id, operation_id).await?;
Ok(OperationMutationResult { Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(), operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(),
version: summary.current_draft_version, version: summary.current_draft_version,
status: OperationStatus::Archived, status: OperationStatus::Archived,
updated_at: format_timestamp(updated_at), updated_at: authoritative.updated_at,
}) })
} }
@@ -400,11 +548,12 @@ impl AdminService {
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
operation_id: &OperationId, operation_id: &OperationId,
expected_state: &OperationStateExpectation,
) -> Result<OperationMutationResult, ApiError> { ) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?; let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = now_string()?; let updated_at = now_string()?;
self.registry self.registry
.delete_operation(workspace_id, operation_id) .delete_operation_cas(workspace_id, operation_id, expected_state)
.await?; .await?;
Ok(OperationMutationResult { Ok(OperationMutationResult {
@@ -422,64 +571,81 @@ impl AdminService {
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
operation_id: &OperationId, operation_id: &OperationId,
payload: TestRunPayload, payload: TestRunPayload,
request_id: &str, correlation: &crank_core::CorrelationContext,
) -> Result<TestRunResult, ApiError> { ) -> Result<TestRunResult, ApiError> {
let runtime_request_context = RuntimeRequestContext::from_request_id(request_id) let request_id = correlation.request_id().as_str();
let trace_id = correlation.trace_id().as_str();
let mut runtime_request_context = RuntimeRequestContext::from_correlation(correlation)
.with_metering_context(workspace_id.clone(), None, InvocationSource::AdminTestRun); .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 let record = self
.get_operation_version(workspace_id, operation_id, payload.version) .get_operation_version(workspace_id, operation_id, payload.version)
.await?; .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 runtime = RuntimeOperation::from(record.snapshot.clone());
let mode = ExecutionMode::Unary; let mode = ExecutionMode::Unary;
let request_preview =
match build_request_preview(&record.snapshot.input_mapping, &payload.input) {
Ok(preview) => preview,
Err(error) => {
self.record_invocation(InvocationRecordRequest {
workspace_id,
agent_id: None,
operation: &record.snapshot,
request_id: Some(request_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,
))],
});
}
};
let resolved_auth = self let resolved_auth = self
.resolve_operation_auth(workspace_id, &runtime.execution_config) .resolve_operation_auth(workspace_id, &runtime.execution_config, None)
.await; .await;
let started_at = std::time::Instant::now(); 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) => { Ok(resolved_auth) => {
self.runtime match RuntimeExecutionRequest::try_new(
.execute_request( workspace_id,
RuntimeExecutionRequest::new(&runtime, &payload.input) ExecutionOrigin::AdminDraft,
.with_optional_auth(resolved_auth.as_ref()) None,
.with_context(&runtime_request_context), &runtime,
) &payload.input,
.await 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), }
} { Err(error) => Err(crank_runtime::normalize_runtime_error(&error, &correlation)),
Ok(response_preview) => { };
match execution_result {
Ok(success) => {
let response_preview = crank_core::sanitize_invocation_preview(&success.output);
let request_preview = success.request_preview;
let duration_ms = let duration_ms =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
self.record_invocation(InvocationRecordRequest { self.record_invocation(InvocationRecordRequest {
@@ -487,26 +653,34 @@ impl AdminService {
agent_id: None, agent_id: None,
operation: &record.snapshot, operation: &record.snapshot,
request_id: Some(request_id), request_id: Some(request_id),
trace_id: Some(trace_id),
source: InvocationSource::AdminTestRun, source: InvocationSource::AdminTestRun,
level: InvocationLevel::Info, level: InvocationLevel::Info,
status: InvocationStatus::Ok, status: InvocationStatus::Ok,
message: "admin test run completed".to_owned(), message: "admin test run completed".to_owned(),
status_code: None, status_code: None,
error_kind: 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, duration_ms,
request_preview: request_preview.clone(), request_preview: request_preview.clone(),
response_preview: response_preview.clone(), response_preview: response_preview.clone(),
}) })
.await?; .await;
Ok(TestRunResult { Ok(TestRunResult {
ok: true, ok: true,
mode, mode,
tested_version: payload.version,
request_id: request_id.to_owned(),
trace_id: trace_id.to_owned(),
request_preview, request_preview,
response_preview, response_preview,
errors: Vec::new(), errors: Vec::new(),
}) })
} }
Err(error) => { Err(failure) => {
let duration_ms = let duration_ms =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
self.record_invocation(InvocationRecordRequest { self.record_invocation(InvocationRecordRequest {
@@ -514,23 +688,33 @@ impl AdminService {
agent_id: None, agent_id: None,
operation: &record.snapshot, operation: &record.snapshot,
request_id: Some(request_id), request_id: Some(request_id),
trace_id: Some(trace_id),
source: InvocationSource::AdminTestRun, source: InvocationSource::AdminTestRun,
level: InvocationLevel::Error, level: InvocationLevel::Error,
status: InvocationStatus::Error, status: InvocationStatus::Error,
message: error.to_string(), message: "runtime execution failed".to_owned(),
status_code: None, status_code: failure.upstream_status(),
error_kind: Some(runtime_error_code(&error).to_owned()), 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, duration_ms,
request_preview: request_preview.clone(), request_preview: Value::Null,
response_preview: Value::Null, response_preview: Value::Null,
}) })
.await?; .await;
Ok(TestRunResult { Ok(TestRunResult {
ok: false, ok: false,
mode, 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, response_preview: Value::Null,
errors: vec![crate::error::runtime_test_failure(&error)], errors: vec![crate::error::execution_test_failure_localized(
&failure, locale,
)],
}) })
} }
} }
+6 -1
View File
@@ -50,6 +50,7 @@ impl AdminService {
.save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata }) .save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata })
.await?; .await?;
info!( info!(
name: "admin.sample.saved",
operation_id = %operation_id.as_str(), operation_id = %operation_id.as_str(),
sample_id = %metadata.id.as_str(), sample_id = %metadata.id.as_str(),
version, version,
@@ -119,7 +120,11 @@ impl AdminService {
input_mapping, input_mapping,
output_mapping, output_mapping,
}; };
info!(operation_id = %operation_id.as_str(), "draft generated from samples"); info!(
name: "admin.operation_draft.generated",
operation_id = %operation_id.as_str(),
"draft generated from samples"
);
Ok(result) Ok(result)
} }
+562 -38
View File
@@ -12,7 +12,8 @@ use tracing::{info, instrument};
use crate::{ use crate::{
error::ApiError, error::ApiError,
service::{ 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( pub async fn create_secret(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
created_by: Option<&UserId>, created_by: Option<&UserId>,
payload: SecretPayload, payload: SecretPayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<Secret, ApiError> { ) -> Result<Secret, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
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);
}
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 now = OffsetDateTime::now_utc();
let secret = Secret { let secret = Secret {
@@ -82,77 +114,322 @@ impl AdminService {
let ciphertext = self let ciphertext = self
.secret_crypto .secret_crypto
.encrypt(&payload.value) .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 { .create_secret(CreateSecretRequest {
secret: &secret, secret: &secret,
ciphertext: &ciphertext, ciphertext: &ciphertext,
key_version: self.secret_crypto.key_version(), key_version: self.secret_crypto.key_version(),
master_key_epoch: self.secret_crypto.master_key_epoch(),
created_by, created_by,
}) })
.await?; .await
info!(secret_id = %secret.id.as_str(), "secret created"); {
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) 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( pub async fn rotate_secret(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
secret_id: &SecretId, secret_id: &SecretId,
created_by: Option<&UserId>, created_by: Option<&UserId>,
payload: RotateSecretPayload, payload: RotateSecretPayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<Secret, ApiError> { ) -> Result<Secret, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
if payload.value.is_null() { self.record_credential_audit(
return Err(ApiError::validation("secret value must not be null")); 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 now = OffsetDateTime::now_utc();
let ciphertext = self let ciphertext = self
.secret_crypto .secret_crypto
.encrypt(&payload.value) .encrypt(&payload.value)
.map_err(|error| ApiError::internal(error.to_string()))?; .map_err(|error| ApiError::internal(error.to_string()));
self.registry 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 { .rotate_secret(RotateSecretRequest {
workspace_id, workspace_id,
secret_id, secret_id,
ciphertext: &ciphertext, ciphertext: &ciphertext,
key_version: self.secret_crypto.key_version(), key_version: self.secret_crypto.key_version(),
master_key_epoch: self.secret_crypto.master_key_epoch(),
created_at: &now, created_at: &now,
updated_at: &now, updated_at: &now,
created_by, created_by,
}) })
.await?; .await
info!(secret_id = %secret_id.as_str(), "secret rotated"); {
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( pub async fn delete_secret(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
secret_id: &SecretId, secret_id: &SecretId,
audit_context: Option<&AdminAuditContext>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
self.ensure_workspace_exists(workspace_id).await?; if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
if let Some(profile) = self 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 .registry
.list_auth_profiles_referencing_secret(workspace_id, secret_id) .list_auth_profiles_referencing_secret(workspace_id, secret_id)
.await? .await
.into_iter()
.next()
{ {
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 { return Err(RegistryError::SecretReferencedByAuthProfile {
secret_id: secret_id.as_str().to_owned(), secret_id: secret_id.as_str().to_owned(),
auth_profile_id: profile.id.as_str().to_owned(), auth_profile_id: profile.id.as_str().to_owned(),
} }
.into()); .into());
} }
self.registry.delete_secret(workspace_id, secret_id).await?; if let Err(error) = self.registry.delete_secret(workspace_id, secret_id).await {
info!(secret_id = %secret_id.as_str(), "secret deleted"); 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(()) Ok(())
} }
@@ -173,35 +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( pub async fn create_auth_profile(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
payload: AuthProfilePayload, payload: AuthProfilePayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<AuthProfile, ApiError> { ) -> Result<AuthProfile, ApiError> {
validate_auth_profile_kind(payload.kind, &payload.config)?; if let Err(error) = validate_auth_profile_payload(&payload) {
self.ensure_workspace_exists(workspace_id).await?; self.record_credential_audit(
self.validate_auth_profile_secret_ids(workspace_id, &payload.config) audit_context,
.await?; 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 now = OffsetDateTime::now_utc();
let profile = AuthProfile { let profile = AuthProfile {
id: AuthProfileId::new(new_prefixed_id("auth")), id: AuthProfileId::new(new_prefixed_id("auth")),
workspace_id: workspace_id.clone(), workspace_id: workspace_id.clone(),
name: payload.name, name: payload.name.trim().to_owned(),
kind: payload.kind, kind: payload.kind,
config: payload.config, config: payload.config,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
self.registry if let Err(error) = self
.registry
.save_auth_profile(SaveAuthProfileRequest { .save_auth_profile(SaveAuthProfileRequest {
workspace_id, workspace_id,
profile: &profile, profile: &profile,
}) })
.await?; .await
info!(auth_profile_id = %profile.id.as_str(), "auth profile created"); {
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) Ok(profile)
} }
@@ -212,13 +572,45 @@ impl AdminService {
config: &AuthConfig, config: &AuthConfig,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
for secret_id in config.secret_ids() { 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(()) 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> { fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> {
let is_match = matches!( let is_match = matches!(
(kind, config), (kind, config),
@@ -236,13 +628,145 @@ fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(),
} }
fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> { fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() { const MAX_SECRET_NAME_CHARS: usize = 128;
return Err(ApiError::validation("secret name must not be empty"));
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")); 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(()) Ok(())
} }
+5 -1
View File
@@ -75,7 +75,11 @@ impl AdminService {
upstream: &upstream, upstream: &upstream,
}) })
.await?; .await?;
info!(upstream_id = %upstream.id.as_str(), "workspace upstream saved"); info!(
name: "admin.upstream.saved",
upstream_id = %upstream.id.as_str(),
"workspace upstream saved"
);
Ok(upstream) Ok(upstream)
} }
+1
View File
@@ -97,6 +97,7 @@ impl AdminService {
user, user,
memberships, memberships,
current_workspace_id: Some(workspace_id.as_str().to_owned()), current_workspace_id: Some(workspace_id.as_str().to_owned()),
csrf_token: self.rotate_session_csrf_token(session_id).await?,
}) })
} }
+6
View File
@@ -1,8 +1,14 @@
use crate::service::AdminService; use crate::service::AdminService;
use crank_runtime::RequestRateLimiter; use crank_runtime::RequestRateLimiter;
use std::net::IpAddr;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub service: AdminService, pub service: AdminService,
pub api_rate_limiter: RequestRateLimiter, pub api_rate_limiter: RequestRateLimiter,
/// Immediate peer IPs allowed to supply `X-Real-IP` / `X-Forwarded-For`.
///
/// 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<IpAddr>,
} }
+275
View File
@@ -0,0 +1,275 @@
use std::{
fs,
io::Read,
os::unix::fs::PermissionsExt,
process::{Command, Stdio},
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
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";
static NEXT_STORAGE_ROOT: AtomicU64 = AtomicU64::new(0);
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", TEST_MASTER_KEY),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
]);
for (name, value) in entries {
command.env(name, value);
}
let output = command.output().expect("admin binary executes");
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).expect("stderr is UTF-8");
assert!(!stderr.contains("CANARY_SECRET_VALUE"));
assert!(!stderr.contains("connection refused"));
assert!(stderr.len() <= 65_536);
stderr
}
#[test]
fn invalid_config_fails_before_database_or_listener_side_effects() {
for (entries, code) in [
(
vec![("CRANK_CONFIG_CANARY_UNKNOWN", "CANARY_SECRET_VALUE")],
"config.unknown_field",
),
(vec![("POSTGRES_PORT", "bad")], "config.invalid_type"),
(
vec![
("CRANK_DATABASE_URL", "postgres://db/crank"),
("POSTGRES_HOST", "other"),
],
"config.conflict",
),
(vec![("CRANK_LOG_LEVEL", "[")], "config.invalid_type"),
] {
let stderr = run_with(&entries);
assert!(stderr.contains(code), "{stderr}");
}
}
#[test]
fn database_driver_failures_are_normalized_and_redacted() {
let stderr = run_with(&[(
"CRANK_DATABASE_URL",
"postgres://CANARY_SECRET_VALUE:CANARY_SECRET_VALUE@127.0.0.1:1/crank",
)]);
assert!(stderr.contains("startup_failed"), "{stderr}");
}
#[tokio::test]
async fn fresh_database_startup_is_read_only() {
let database_url = crank_test_support::postgres_schema_url("admin_startup_read_only").await;
let stderr = run_with(&[("CRANK_DATABASE_URL", &database_url)]);
assert!(stderr.contains("schema_missing"), "{stderr}");
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
let present: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!present);
}
#[tokio::test]
async fn real_admin_startup_runs_immediate_import_maintenance() {
let unique = NEXT_STORAGE_ROOT.fetch_add(1, Ordering::Relaxed);
let epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let database_url =
crank_test_support::postgres_schema_url(&format!("startup_maint_{epoch}_{unique}")).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();
assert_eq!(
sqlx::query_scalar::<_, Option<i64>>("select max(version) from __crank_migrations")
.fetch_one(&pool)
.await
.unwrap(),
Some(13),
"migration binary must install the current canonical ledger",
);
sqlx::query(
"insert into import_jobs (
id, workspace_id, kind, source_format, source_version, status,
preview_payload, created_operation_ids, error_text, created_at, expires_at, finished_at
) values (
'imp_startup_expired', 'ws_default', 'openapi', 'openapi', null, 'pending',
'{\"source\": {\"source_id\": 1}}'::jsonb, '[]'::jsonb, null,
now() - interval '2 hours', now() - interval '1 hour', null
)",
)
.execute(&pool)
.await
.unwrap();
let storage_root = std::env::temp_dir().join(format!(
"crank-admin-startup-maintenance-{}-{epoch}-{unique}",
std::process::id()
));
fs::create_dir(&storage_root).unwrap();
fs::set_permissions(&storage_root, fs::Permissions::from_mode(0o700)).unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let bind_addr = listener.local_addr().unwrap().to_string();
drop(listener);
let mut command = Command::new(env!("CARGO_BIN_EXE_admin-api"));
for field in crank_config::field_registry() {
command.env_remove(field.env_name);
}
let mut child = command
.envs([
("CRANK_DATABASE_URL", database_url.as_str()),
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
("CRANK_ADMIN_BIND", bind_addr.as_str()),
("CRANK_STORAGE_ROOT", storage_root.to_str().unwrap()),
])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("admin binary starts");
let mut cleaned = false;
for _ in 0..200 {
if let Some(status) = child.try_wait().unwrap() {
let mut stderr = String::new();
if let Some(mut stream) = child.stderr.take() {
stream.read_to_string(&mut stderr).unwrap();
}
panic!("admin binary exited before startup maintenance: {status}: {stderr}");
}
let remaining: i64 =
sqlx::query_scalar("select count(*) from import_jobs where id = 'imp_startup_expired'")
.fetch_one(&pool)
.await
.unwrap();
if remaining == 0 {
cleaned = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
child.kill().expect("admin binary can be stopped");
child.wait().expect("admin binary can be reaped");
fs::remove_dir_all(&storage_root).unwrap();
assert!(cleaned, "real startup did not clean the expired import job");
}
#[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);
}
+208
View File
@@ -0,0 +1,208 @@
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::{io, sync::Mutex};
use axum::{Json, Router, extract::State, routing::post};
use crank_core::{OperationId, WorkspaceId};
use serde_json::{Value, json};
use serial_test::serial;
use tokio::{net::TcpListener, sync::Notify};
use tracing_subscriber::fmt::MakeWriter;
#[path = "integration/common.rs"]
mod common;
use common::*;
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn preserves_external_success_when_invocation_history_is_lost() {
let registry = test_registry().await;
let registry_for_failure = registry.clone();
let storage_root = test_storage_root("observability_history_loss");
let upstream = spawn_blocking_upstream_server().await;
let log_writer = SharedLogWriter::default();
let subscriber = crank_observability::build_subscriber(
crank_observability::ObservabilityConfig::new(
crank_observability::ServiceIdentity::try_new("admin-api", "test", "test").unwrap(),
"info",
crank_observability::RedactionLimits::default(),
),
log_writer.clone(),
)
.unwrap();
tracing::subscriber::set_global_default(subscriber).unwrap();
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_history_loss",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let request_client = client.clone();
let request_url = format!("{base_url}/operations/{operation_id}/test-runs");
let before = crank_observability::operational_incident_total(
crank_observability::OperationalIncident::InvocationHistoryLost,
);
let request = tokio::spawn(async move {
request_client
.post(request_url)
.header("x-request-id", "req_dc08_admin")
.json(&json!({
"version": 1,
"input": { "email": "dc08-canary-secret@example.com" }
}))
.send()
.await
.unwrap()
});
upstream.started.notified().await;
registry_for_failure
.delete_operation(
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&OperationId::new(operation_id.clone()),
)
.await
.unwrap();
upstream.release.notify_one();
let response = request.await.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(
response.headers()["x-request-id"].to_str().unwrap(),
"req_dc08_admin"
);
let body = response.json::<Value>().await.unwrap();
assert_eq!(body["ok"], true);
assert_eq!(body["response_preview"]["id"], "lead_123");
assert_eq!(upstream.calls.load(Ordering::SeqCst), 1);
assert!(
crank_observability::operational_incident_total(
crank_observability::OperationalIncident::InvocationHistoryLost
) > before
);
let logs = client
.get(format!("{base_url}/logs?period=7d"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert!(logs["items"].as_array().unwrap().is_empty());
let output = log_writer.output();
assert!(!output.contains("dc08-canary-secret"));
let incident = output
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.find(|event| event["event"] == "admin.invocation_history.lost")
.expect("DC-08 incident");
assert_eq!(incident["request_id"], "req_dc08_admin");
assert_eq!(incident["fields"]["source"], "admin_test_run");
}
struct BlockingUpstream {
base_url: String,
started: Arc<Notify>,
release: Arc<Notify>,
calls: Arc<AtomicUsize>,
}
#[derive(Clone)]
struct BlockingUpstreamState {
started: Arc<Notify>,
release: Arc<Notify>,
calls: Arc<AtomicUsize>,
}
async fn spawn_blocking_upstream_server() -> BlockingUpstream {
let state = BlockingUpstreamState {
started: Arc::new(Notify::new()),
release: Arc::new(Notify::new()),
calls: Arc::new(AtomicUsize::new(0)),
};
let app = Router::new()
.route("/crm/leads", post(blocking_create_lead))
.with_state(state.clone());
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();
});
BlockingUpstream {
base_url: format!("http://{address}"),
started: state.started,
release: state.release,
calls: state.calls,
}
}
async fn blocking_create_lead(
State(state): State<BlockingUpstreamState>,
Json(payload): Json<Value>,
) -> Json<Value> {
state.calls.fetch_add(1, Ordering::SeqCst);
state.started.notify_one();
state.release.notified().await;
Json(json!({
"id": "lead_123",
"status": "created",
"email": payload["email"]
}))
}
#[derive(Clone, Default)]
struct SharedLogWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl SharedLogWriter {
fn output(&self) -> String {
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
}
}
impl<'a> MakeWriter<'a> for SharedLogWriter {
type Writer = SharedLogGuard;
fn make_writer(&'a self) -> Self::Writer {
SharedLogGuard {
buffer: Arc::clone(&self.buffer),
}
}
}
struct SharedLogGuard {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl io::Write for SharedLogGuard {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
+7
View File
@@ -1,8 +1,15 @@
mod integration { mod integration {
mod agent_catalog;
mod auth_rate_limit; mod auth_rate_limit;
mod common; mod common;
mod community_access_usage; mod community_access_usage;
mod credential_lifecycle;
mod logs_usage;
mod onboarding;
mod openapi_import; mod openapi_import;
mod openapi_source;
mod operation_lifecycle;
mod operations_agents; mod operations_agents;
mod request_context;
mod secrets_import_auth; mod secrets_import_auth;
} }
@@ -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::<Value>().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::<Value>().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");
}
@@ -11,17 +11,22 @@ use std::{
use async_trait::async_trait; use async_trait::async_trait;
use axum::{Json, Router, routing::post}; use axum::{Json, Router, routing::post};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{ use crank_core::{
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
}; };
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
use crank_mapping::{MappingRule, MappingSet}; 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_runtime::SecretCrypto;
use crank_schema::{Schema, SchemaKind}; use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json}; use serde_json::{Value, json};
use serial_test::serial; use serial_test::serial;
use sha2::{Digest, Sha256};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use admin_api::{ use admin_api::{
@@ -36,7 +41,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret"; 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 { struct TestServer {
base_url: String, 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")] #[tokio::test(flavor = "multi_thread")]
#[serial] #[serial]
async fn rejects_rapid_login_requests_with_429() { async fn rejects_rapid_login_requests_with_429() {
@@ -109,10 +156,14 @@ async fn rejects_rapid_login_requests_with_429() {
api_rate_limiter: crank_runtime::RequestRateLimiter::new( api_rate_limiter: crank_runtime::RequestRateLimiter::new(
crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(), crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(),
), ),
trusted_proxy_ips: Vec::new(),
}); });
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
axum::serve(listener, app) axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move { .with_graceful_shutdown(async move {
let _ = shutdown_rx.await; let _ = shutdown_rx.await;
}) })
@@ -191,3 +242,119 @@ async fn login_uses_identity_provider_when_configured() {
}; };
assert_eq!(error.to_string(), "invalid email or password"); 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));
}
+161 -10
View File
@@ -10,12 +10,12 @@ use std::{
use async_trait::async_trait; use async_trait::async_trait;
use axum::{Json, Router, routing::post}; use axum::{Json, Router, routing::post};
use crank_core::{ use crank_core::{
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, AuditSink, ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
}; };
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
use crank_mapping::{MappingRule, MappingSet}; use crank_mapping::{MappingRule, MappingSet};
use crank_registry::PostgresRegistry; use crank_registry::{MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry};
use crank_runtime::SecretCrypto; use crank_runtime::SecretCrypto;
use crank_schema::{Schema, SchemaKind}; use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json}; 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_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret"; 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 { pub(super) struct TestServer {
base_url: String, base_url: String,
@@ -104,6 +104,50 @@ pub(super) fn build_test_app(
api_rate_limiter: crank_runtime::RequestRateLimiter::new( api_rate_limiter: crank_runtime::RequestRateLimiter::new(
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(), crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
), ),
trusted_proxy_ips: Vec::new(),
})
}
pub(super) fn build_test_app_with_external_references(
registry: PostgresRegistry,
storage_root: std::path::PathBuf,
allowed_url_prefixes: Vec<String>,
) -> Router {
build_app(AppState {
service: test_service_with_external_references(
registry,
storage_root,
allowed_url_prefixes,
),
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
),
trusted_proxy_ips: Vec::new(),
})
}
pub(super) fn build_test_app_with_audit_sink(
registry: PostgresRegistry,
storage_root: std::path::PathBuf,
audit_sink: Arc<dyn AuditSink>,
) -> 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(),
}) })
} }
@@ -113,13 +157,43 @@ pub(super) fn test_service(
auth_settings: AuthSettings, auth_settings: AuthSettings,
secret_crypto: SecretCrypto, secret_crypto: SecretCrypto,
) -> AdminService { ) -> AdminService {
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
AdminServiceBuilder::new( AdminServiceBuilder::new(
registry, registry,
storage_root, storage_root,
auth_settings, auth_settings,
secret_crypto, secret_crypto,
crank_runtime::RuntimeExecutor::new(), runtime,
) )
.with_outbound_http_policy(outbound_policy)
.build()
}
pub(super) fn test_service_with_external_references(
registry: PostgresRegistry,
storage_root: std::path::PathBuf,
allowed_url_prefixes: Vec<String>,
) -> AdminService {
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
AdminServiceBuilder::new(
registry,
storage_root,
test_auth_settings(),
test_secret_crypto(),
runtime,
)
.with_external_reference_import(&crank_config::ExternalReferenceSettings {
allowed_url_prefixes,
max_depth: 8,
max_documents: 32,
max_fetch_bytes: 64 * 1024,
fetch_timeout_ms: 2_000,
max_expanded_nodes: 10_000,
})
.unwrap()
.with_outbound_http_policy(outbound_policy)
.build() .build()
} }
@@ -165,12 +239,13 @@ pub(super) async fn authorized_client(workspace_base_url: impl AsRef<str>) -> re
.split("/api/admin/workspaces/") .split("/api/admin/workspaces/")
.next() .next()
.unwrap(); .unwrap();
let client = reqwest::Client::builder() let cookie_jar = Arc::new(reqwest::cookie::Jar::default());
.cookie_store(true) let login_client = reqwest::Client::builder()
.cookie_provider(cookie_jar.clone())
.build() .build()
.unwrap(); .unwrap();
client let login = login_client
.post(format!("{root_url}/api/auth/login")) .post(format!("{root_url}/api/auth/login"))
.json(&json!({ .json(&json!({
"email": TEST_AUTH_EMAIL, "email": TEST_AUTH_EMAIL,
@@ -180,9 +255,67 @@ pub(super) async fn authorized_client(workspace_base_url: impl AsRef<str>) -> re
.await .await
.unwrap() .unwrap()
.error_for_status() .error_for_status()
.unwrap()
.json::<Value>()
.await
.unwrap(); .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<str>,
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<str>,
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 { pub(super) async fn assert_success_json(response: reqwest::Response) -> Value {
@@ -207,7 +340,21 @@ pub(super) async fn create_lead(Json(payload): Json<Value>) -> Json<Value> {
pub(super) async fn test_registry() -> PostgresRegistry { pub(super) async fn test_registry() -> PostgresRegistry {
let database_url = crank_test_support::postgres_schema_url("test_admin_api").await; let database_url = crank_test_support::postgres_schema_url("test_admin_api").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
crank_registry::MigrationAuthority::apply(&pool)
.await
.unwrap();
let registry = PostgresRegistry::connect(&database_url).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 password_hash = hash_password(TEST_AUTH_PASSWORD, TEST_PASSWORD_PEPPER).unwrap();
let user_id = registry let user_id = registry
.upsert_bootstrap_user(TEST_AUTH_EMAIL, "Test Owner", &password_hash) .upsert_bootstrap_user(TEST_AUTH_EMAIL, "Test Owner", &password_hash)
@@ -226,14 +373,18 @@ pub(super) async fn test_registry() -> PostgresRegistry {
} }
pub(super) fn test_storage_root(name: &str) -> std::path::PathBuf { pub(super) fn test_storage_root(name: &str) -> std::path::PathBuf {
env::temp_dir().join(format!( let root = env::temp_dir().join(format!(
"crank_admin_api_{name}_{}_{}", "crank_admin_api_{name}_{}_{}",
std::process::id(), std::process::id(),
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_nanos() .as_nanos()
)) ));
std::fs::create_dir_all(&root).unwrap();
#[cfg(unix)]
std::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap();
root
} }
pub(super) fn test_auth_settings() -> AuthSettings { pub(super) fn test_auth_settings() -> AuthSettings {
@@ -23,6 +23,7 @@ use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json}; use serde_json::{Value, json};
use serial_test::serial; use serial_test::serial;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use uuid::Version;
use admin_api::{ use admin_api::{
app::build_app, app::build_app,
@@ -38,7 +39,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret"; 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 { struct TestServer {
base_url: String, base_url: String,
@@ -385,6 +386,30 @@ async fn exports_single_workspace_but_rejects_access_lifecycle() {
exported["workspace"]["workspace"]["id"], exported["workspace"]["workspace"]["id"],
DEFAULT_WORKSPACE_ID DEFAULT_WORKSPACE_ID
); );
assert_eq!(exported["kind"], "workspace_catalog_snapshot");
assert_eq!(exported["format_version"], "1");
assert_eq!(exported["restorable"], false);
assert_eq!(
exported["included"],
json!([
"workspace_settings",
"operation_summaries",
"agent_summaries",
"platform_api_key_metadata"
])
);
assert!(
exported["excluded"]
.as_array()
.unwrap()
.contains(&json!("secret_values"))
);
assert!(
exported["excluded"]
.as_array()
.unwrap()
.contains(&json!("invocation_logs_and_usage"))
);
assert!(exported.get("memberships").is_none()); assert!(exported.get("memberships").is_none());
assert!(exported.get("invitations").is_none()); assert!(exported.get("invitations").is_none());
@@ -453,6 +478,11 @@ async fn seeds_demo_assets_for_live_ui() {
&WorkspaceId::new(DEFAULT_WORKSPACE_ID), &WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&smoke_operation_id, &smoke_operation_id,
smoke_operation.version, smoke_operation.version,
&crank_registry::OperationStateExpectation {
current_draft_version: smoke_operation.version,
status: crank_core::OperationStatus::Draft,
latest_published_version: None,
},
) )
.await .await
.unwrap(); .unwrap();
@@ -464,7 +494,7 @@ async fn seeds_demo_assets_for_live_ui() {
display_name: "Legacy Smoke Agent".to_owned(), display_name: "Legacy Smoke Agent".to_owned(),
description: "Keeps a legacy smoke operation published".to_owned(), description: "Keeps a legacy smoke operation published".to_owned(),
instructions: json!({}), instructions: json!({}),
tool_selection_policy: json!({}), tool_selection_policy: Default::default(),
}, },
) )
.await .await
@@ -481,7 +511,9 @@ async fn seeds_demo_assets_for_live_ui() {
tool_title: "Legacy health smoke".to_owned(), tool_title: "Legacy health smoke".to_owned(),
tool_description_override: None, tool_description_override: None,
enabled: true, enabled: true,
}], }]
.into(),
None,
) )
.await .await
.unwrap(); .unwrap();
@@ -490,6 +522,7 @@ async fn seeds_demo_assets_for_live_ui() {
&WorkspaceId::new(DEFAULT_WORKSPACE_ID), &WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&smoke_agent_id, &smoke_agent_id,
smoke_agent.version, smoke_agent.version,
None,
) )
.await .await
.unwrap(); .unwrap();
@@ -547,17 +580,22 @@ async fn seeds_demo_assets_for_live_ui() {
&default_workspace_id, &default_workspace_id,
admin_api::service::LogsQuery { admin_api::service::LogsQuery {
level: None, level: None,
status: None,
search: None, search: None,
source: None, source: None,
operation_id: None, operation_id: None,
agent_id: None, agent_id: None,
outcome_group: None,
created_after: None,
created_before: None,
period: None, period: None,
cursor: None,
limit: Some(20), limit: Some(20),
}, },
) )
.await .await
.unwrap(); .unwrap();
assert!(!logs.is_empty()); assert!(!logs.items.is_empty());
} }
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
@@ -573,6 +611,25 @@ async fn updates_profile_and_changes_password() {
.unwrap() .unwrap()
.to_owned(); .to_owned();
let client = authorized_client(&base_url).await; let client = authorized_client(&base_url).await;
let second_client = reqwest::Client::builder()
.cookie_store(true)
.build()
.unwrap();
let second_login = second_client
.post(format!("{root_url}/api/auth/login"))
.json(&json!({
"email": TEST_AUTH_EMAIL,
"password": TEST_AUTH_PASSWORD,
}))
.send()
.await
.unwrap();
let second_login_status = second_login.status();
let second_login_body = second_login.text().await.unwrap();
assert!(
second_login_status.is_success(),
"second login failed with {second_login_status}: {second_login_body}"
);
let profile = assert_success_json( let profile = assert_success_json(
client client
@@ -601,9 +658,11 @@ async fn updates_profile_and_changes_password() {
updated_profile["user"]["email"], updated_profile["user"]["email"],
"updated-owner@crank.local" "updated-owner@crank.local"
); );
let updated_csrf_token = updated_profile["csrf_token"].as_str().unwrap();
let password_status = client let password_status = client
.post(format!("{root_url}/api/auth/password")) .post(format!("{root_url}/api/auth/password"))
.header("x-csrf-token", updated_csrf_token)
.json(&json!({ .json(&json!({
"current_password": TEST_AUTH_PASSWORD, "current_password": TEST_AUTH_PASSWORD,
"new_password": "updated-password-123" "new_password": "updated-password-123"
@@ -614,6 +673,21 @@ async fn updates_profile_and_changes_password() {
.status(); .status();
assert_eq!(password_status, reqwest::StatusCode::NO_CONTENT); assert_eq!(password_status, reqwest::StatusCode::NO_CONTENT);
let current_session_status = client
.get(format!("{root_url}/api/auth/profile"))
.send()
.await
.unwrap()
.status();
let other_session_status = second_client
.get(format!("{root_url}/api/auth/profile"))
.send()
.await
.unwrap()
.status();
assert_eq!(current_session_status, reqwest::StatusCode::UNAUTHORIZED);
assert_eq!(other_session_status, reqwest::StatusCode::UNAUTHORIZED);
let relogin_client = reqwest::Client::builder() let relogin_client = reqwest::Client::builder()
.cookie_store(true) .cookie_store(true)
.build() .build()
@@ -681,87 +755,6 @@ async fn rejects_multi_workspace_session_switching_in_community() {
assert_eq!(session["current_workspace_id"], DEFAULT_WORKSPACE_ID); 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::<Value>()
.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::<Value>()
.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::<Value>()
.await
.unwrap();
let usage = client
.get(format!("{base_url}/usage?period=7d"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_usage = client
.get(format!(
"{base_url}/usage/operations/{operation_id}?period=7d"
))
.send()
.await
.unwrap()
.json::<Value>()
.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")] #[tokio::test(flavor = "multi_thread")]
#[serial] #[serial]
async fn preserves_request_id_for_test_run_invocations() { async fn preserves_request_id_for_test_run_invocations() {
@@ -854,7 +847,10 @@ async fn generates_request_id_for_test_run_invocations() {
.unwrap() .unwrap()
.to_owned(); .to_owned();
assert!(!request_id.is_empty()); assert_eq!(
uuid::Uuid::parse_str(&request_id).unwrap().get_version(),
Some(Version::SortRand)
);
response.error_for_status().unwrap(); response.error_for_status().unwrap();
let logs = client let logs = client
@@ -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<Mutex<Vec<AuditEvent>>>,
}
impl RecordingAuditSink {
fn events(&self) -> Vec<AuditEvent> {
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<Mutex<Vec<Option<String>>>>) {
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<str>, 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;
}
@@ -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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.await
.unwrap();
let windowed_usage = client
.get(format!(
"{base_url}/usage?created_after={created_after}&created_before={created_before}"
))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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);
}

Some files were not shown because too many files have changed in this diff Show More