Terraducktel API Reference¶
Complete endpoint catalog. Everything is HTTP/JSON over /api/v1.
Conventions¶
- Base URL:
/api/v1for user-facing endpoints, plus a handful of state-token-authenticated/api/v1/state/*,/api/v1/internal/*and/api/v1/webhooks/*paths used by the executor / drift detector / webhook senders. - Auth: Every user-facing endpoint requires a JWT Bearer token
(
Authorization: Bearer …) or an API key (Authorization: Bearer tdt_…, see API Keys below). State + internal + webhook endpoints authenticate via theX-Terraducktel-State-Tokenheader or a webhook HMAC signature instead. - Business Unit scoping: Most user-facing list/write endpoints honor
X-Business-Unit: <slug>. Rules (seeapp/auth/bu_context.py): - Superadmin, no header (or header
all) → all BUs, no filter. - Superadmin, header
<slug>→ that BU (404 if it doesn't exist). - Member, no header → caller's first BU membership (alphabetical by slug).
- Member, header
<slug>→ that BU if the caller is a member (403 otherwise). - Member, header
all→ 403 (only superadmins can view "all"). - An API key always forces its own bound BU — any
X-Business-Unitheader sent alongside a key is ignored. - Endpoints noted as
BU-scopedfilter by the resolved scope; endpoints noted ascross-BUignore it entirely. - Roles:
viewer < operator < admin. Endpoints document the minimum role required. A handful of routers (Users, API Keys, Business Units) are interactive-only — they reject API-key callers outright regardless of the key's capability tier, because they touch identity/tenancy rather than workspace operations. - Secrets: GET endpoints for integrations and credentialed resources
(AWS accounts, Azure subscriptions, clusters, variables) never return
plaintext — only
{configured, masked_tail}shapes and (for some) a cached identity (Slack workspace name, GitHub login, etc.). - Pagination: Most list endpoints return the full result set (BU-scoped
collections are small by design). Two exceptions currently paginate
explicitly:
GET /inventory/assets(limit/offsetquery params, defaultlimit=200, max1000) and API-key-authenticated automation is expected to filter client-side otherwise.
JWT format:
- Access token: HS256, default 480min/8h expiry (tunable via
auth.access_token_expire_minutes in Runtime Config), claims: sub (user
id), email, role, is_superadmin, name, type: "access".
- Refresh token: HS256, default 24h expiry (tunable via
auth.refresh_token_expire_hours), claims: sub, type: "refresh".
Auth — /api/v1/auth¶
Local email+password login is always available as a break-glass path, even
when OIDC SSO is the primary provider. OIDC is gated by the auth.provider
config key (local / oidc / both).
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /auth/token |
Local email+password login. Returns access + refresh tokens. | public |
| POST | /auth/refresh |
Redeem a refresh token for a fresh access+refresh pair. Rejects access tokens, run-scoped executor tokens and tdt_ API keys. |
public (bearer not required — the body carries the token) |
| GET | /auth/config |
Public auth config: {mode, oidc_enabled, oidc_issuer, cli_loopback}. |
public |
| GET | /auth/oidc/login |
Begin OIDC login redirect for the configured provider. 404 if OIDC isn't enabled; 503 if enabled but not configured. Optional cli_port + cli_nonce query params switch the callback to the CLI loopback hand-off (see below). |
public |
| GET | /auth/oidc/callback |
OIDC callback target — exchanges the auth code for tokens, upserts the local user, redirects the browser to /auth/oidc-finish?access_token=…&refresh_token=…. |
public |
POST /auth/token body {"email": "...", "password": "..."} →
{"access_token", "refresh_token", "token_type": "bearer"}. 401 on bad
credentials.
OIDC role mapping (tested against a generic OIDC-compliant IdP) reads a
configurable claim (default group/role claim) and maps it to a TDT role +
is_superadmin; see services/api/app/auth/oidc.py for the config-key contract.
POST /auth/refresh body {"refresh_token": "..."} → a new
{access_token, refresh_token, token_type}.
Both /auth/token and the OIDC callback have always minted a refresh token;
this is what redeems one. A new refresh token comes back each time, so an
actively-used client slides forward indefinitely and never re-authenticates.
Tokens are stateless — there is no server-side revocation list — so a leaked
refresh token stays usable until it expires
(auth.refresh_token_expire_hours, default 24). Keep that TTL short, and
revoke access by deleting the user. Claims are re-read from the user row on
every refresh, so a demotion takes effect on the next one rather than
persisting for the life of the token.
CLI loopback sign-in. GET /auth/oidc/login?cli_port=<1024-65535>&cli_nonce=<16-128 url-safe chars>
makes the callback hand the token pair to a listener on http://127.0.0.1:<cli_port>/callback
instead of redirecting to the SPA. The IdP's registered redirect_uri is
unchanged — it still points at this API's own callback — and cli_port travels
in the signed session cookie rather than the callback URL, so a crafted callback
cannot retarget the hand-off. The redirect host is hardcoded to 127.0.0.1.
GET /auth/config advertises cli_loopback: true so a client can tell whether
a deployment supports this before trying.
Users — /api/v1/users¶
Lists users and edits superadmin status / per-Business-Unit memberships.
Interactive-only — the whole router rejects API-key callers, even
admin-tier ones, since identity management must never be automatable.
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /users |
List all users with their per-BU memberships. | admin |
| GET | /users/eligible-reviewers |
Deprecated — 4-eyes was removed. Always returns []. |
operator |
| PATCH | /users/{user_id} |
Update a user's is_superadmin flag and/or BU memberships. |
superadmin |
PATCH /users/{user_id} body:
{
"is_superadmin": true, // optional
"add_memberships": [ // optional
{"business_unit_id": "<bu-id>", "role": "operator"} // role: operator | viewer
],
"remove_memberships": ["<bu-id>"] // optional, list of business_unit_id
}
current_user.is_superadmin, independent of the admin role on /users
GET). Demoting the last remaining superadmin is rejected with 409. Adding a
membership for a BU that doesn't exist is a 400; adding a membership that
already exists just updates its role.
Response (UserResponse): {id, email, role, auth_provider, external_id,
is_superadmin, memberships: [{business_unit_id, business_unit_slug,
business_unit_name, role}]}.
API Keys — /api/v1/api-keys¶
Long-lived, scoped credentials for automation. Admin-only to manage; the key
itself authenticates as its owner but is capped at its capability tier and
(optionally) a workspace allowlist, and is pinned to one BU. Interactive-only
router — an API key can never mint, rotate, or revoke API keys, even at the
admin tier (privilege-escalation guard).
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /api-keys |
List keys in the current BU (masked — token_prefix, never the token). |
admin |
| POST | /api-keys |
Mint a key. Returns the plaintext token once. |
admin |
| POST | /api-keys/{id}/regenerate |
Rotate an active key's secret in place, keeping name/capability/allowlist/expiry. Returns the new plaintext token once. |
admin |
| POST | /api-keys/{id}/rotate |
Mint a successor key and keep the old secret usable for an overlap window. Returns the new plaintext token once, plus predecessor_id and predecessor_expires_at. |
admin |
| DELETE | /api-keys/{id} |
Soft-revoke a key (immediate 401 for further use). Idempotent. | admin |
POST /api-keys body:
{
"name": "ci-deploy-bot",
"capability": "plan", // read | plan | apply | admin
"workspace_ids": ["<ws-id>"], // optional allowlist; omit/null = whole BU
"expires_at": "2026-12-31T00:00:00Z" // optional; omit = never
}
{ ...key, "token": "tdt_…" } (the only response that carries the plaintext).
Requires a concrete X-Business-Unit — 400 if scoped to "all". A non-empty
workspace_ids is validated against the current BU; any id not in this BU
→ 400. expires_at must be in the future or 400.
POST /api-keys/{id}/regenerate — mints a fresh secret for an existing key
row, clears last_used_at, and keeps everything else (name, capability,
workspace allowlist, expiry) unchanged. The old token stops working
immediately. Rejects with 409 if the key is already revoked or has
already expired — rotation only ever refreshes a live secret; create a new
key instead of trying to revive a dead one.
POST /api-keys/{id}/rotate body {"overlap_hours": 24} (0–168, default 24)
— the option to reach for when more than one consumer holds the key.
regenerate swaps a secret in place and the old one dies that instant, which
is fine for a single consumer and unusable when the same key sits in a CI
secret store, a laptop keychain and a cron job: those cannot be updated
atomically. Rotation creates a second key so both secrets work at once, and
you move consumers over one at a time.
Two rows are involved, because an overlap needs two usable secrets and
token_hash is unique per row:
| old key | expires_at pulled in to now + overlap_hours, rotated_at set, superseded_by_id → the successor. Nothing else changes, so it keeps authenticating until the window closes. |
| new key | same name, capability, workspace allowlist, owner and BU; fresh secret, clean last_used_at. |
{ ...successorKey,
"token": "tdt_…", // once, as on create
"predecessor_id": "…",
"predecessor_expires_at": "2026-09-02T14:45:01Z" } // deadline for the swap
The window rides the ordinary expires_at check, so the old key stops on its
own — there is nothing to call afterwards and no cleanup job. overlap_hours: 0
is an immediate cutover.
Constraints:
- A rotation never extends a credential's life. If the old key already expires sooner than the requested window, the earlier deadline wins.
- A key can be rotated once. Chaining would leave several live secrets behind one name; 409 names the successor to rotate instead.
- Revoked or expired keys are refused with 409, as with
regenerate.
rotated_at and superseded_by_id appear on every key in GET /api-keys, so a
client can tell "expiring because an admin said so" from "retiring because it
was rotated".
DELETE /api-keys/{id} — sets revoked_at; calling it again on an
already-revoked key is a no-op 200 (idempotent), not a 404/409.
Using a key: send Authorization: Bearer tdt_…. The key forces its own BU
(any X-Business-Unit header is ignored). Capability gates:
- read — viewer reads.
- plan — trigger plan-only runs (+cancel).
- apply — also trigger apply/destroy + approve/reject.
- admin — full admin within the BU: everything apply can do plus
workspace create / discover / import / update / delete, AWS accounts, Azure
subscriptions, clusters, policies, drift, integrations, variables. Use this
for CLI-driven workspace discovery and onboarding. ⚠️ Powerful — treat the
token like an admin password.
Always interactive-only, even for an admin key: minting/rotating/revoking
API keys, user management, and Business-Unit create/update. A key is bound to
one BU and can never act across BUs. Requests outside the workspace allowlist
→ 403; revoked/expired keys → 401.
Policies — /api/v1/policies¶
BU-scoped OPA/conftest rego rules. The executor enforces them (see the OPA
policy gate in /integrations/opa); these endpoints author + test them. Every
write snapshots into policy_versions and is audit-logged.
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /policies |
List the BU's policies. | viewer |
| POST | /policies |
Create (snapshots v1). Body: {name, rego, description?, tests_rego?, severity?, enabled?}. |
admin |
| POST | /policies/test |
Dry-run vs a plan. Body: {run_id\|plan_json, rego?, rego_name?, rego_severity?, policy_ids?}. → {ok, violations[], warnings[], engine_error?}. |
operator |
| POST | /policies/verify |
Run rego unit tests. Body: {rego, tests_rego}. → {ok, passed, failures[], engine_error?}. |
operator |
| GET | /policies/{id} |
Policy detail (full rego). | viewer |
| PUT | /policies/{id} |
Edit (bumps version). Body: any subset of the create fields. | admin |
| DELETE | /policies/{id} |
Delete the policy + its history. | admin |
| GET | /policies/{id}/versions |
Revision list (newest first). | viewer |
| POST | /policies/{id}/versions/{v}/restore |
Restore a revision into a new current version. | admin |
severity is block (fails the run under enforce mode) | warn | info.
POST /policies/test requires exactly one plan source (run_id or plan_json);
the policy set is the candidate rego and/or policy_ids, else all enabled
policies in the BU. Every write requires a concrete X-Business-Unit — 400 if
scoped to "all".
Executor-facing: GET /api/v1/runs/{run_id}/policies (operator) returns the
merged gate config + enabled policies for the run's BU — see Runs.
Business Units — /api/v1/business-units¶
Multi-tenancy root. Interactive-only router (API keys are bound to a single BU and can never touch this one, regardless of capability tier).
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /business-units |
List BUs the caller is a member of (superadmin sees all; an API-key caller sees only its own bound BU). | any authenticated caller |
| POST | /business-units |
Create a BU. {slug, name}. 409 if the slug already exists. |
superadmin |
| PUT | /business-units/{bu_id} |
Update a BU's name (slug is immutable post-create). |
superadmin |
slug must match ^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$ (lowercase
letters/digits/hyphens, 3-64 chars, no leading/trailing hyphen).
AWS Accounts — /api/v1/aws-accounts¶
Encrypted-at-rest AWS credentials, one row per account per BU.
| Method | Path | Description | Min role | BU |
|---|---|---|---|---|
| GET | /aws-accounts |
List configured AWS accounts (masked). | viewer | BU-scoped |
| POST | /aws-accounts |
Add an AWS account (creds stored encrypted). | admin | BU-scoped |
| PUT | /aws-accounts/{account_pk} |
Update AWS account creds / display name. | admin | — |
| DELETE | /aws-accounts/{account_pk} |
Delete an AWS account row. | admin | — |
| POST | /aws-accounts/{account_pk}/test |
Probe creds via STS GetCallerIdentity + s3:HeadBucket. |
admin | — |
| POST | /aws-accounts/{account_pk}/bucket |
Create the account's S3 state bucket if missing (idempotent). | admin | — |
POST /aws-accounts body: {account_id (12-digit), name, description?,
state_bucket, state_bucket_region?, default_region?, aws_profile_name?,
access_key_id, secret_access_key}. Requires a concrete X-Business-Unit —
400 if scoped to "all". account_id is unique per BU (the same AWS
account number may legitimately be registered in two different BUs) — a
duplicate within the same BU is 409.
POST .../bucket hardens a freshly created bucket with versioning, AES256
default encryption, and a public-access block; response carries
already_existed so callers can tell a no-op from a real creation.
{account_pk} in the path is the TDT row id (not the 12-digit AWS account
number).
Azure Subscriptions — /api/v1/azure-subscriptions¶
Encrypted-at-rest Azure Service Principal credentials, mirroring AWS Accounts.
Workspaces that target azurerm link one of these; the executor exports
ARM_* env vars from the decrypted SP secret at run time.
| Method | Path | Description | Min role | BU |
|---|---|---|---|---|
| GET | /azure-subscriptions |
List configured subscriptions (masked). | viewer | BU-scoped |
| POST | /azure-subscriptions |
Add a subscription (SP secret stored encrypted). | admin | BU-scoped |
| PUT | /azure-subscriptions/{sub_pk} |
Update name/description/location or rotate the client secret. | admin | — |
| DELETE | /azure-subscriptions/{sub_pk} |
Delete a subscription row. | admin | — |
| POST | /azure-subscriptions/{sub_pk}/test |
Validate the SP creds via an ARM OAuth2 token; also probes the Blob state container when configured. | admin | — |
| POST | /azure-subscriptions/{sub_pk}/container |
Create (or verify) the Blob state container using the SP. | admin | — |
POST /azure-subscriptions body: {subscription_id, tenant_id, client_id
(all UUIDs), client_secret, name, description?, default_location?,
state_storage_account?, state_container?}. Requires a concrete
X-Business-Unit. subscription_id is unique per BU — duplicate → 409.
{sub_pk} in the path is the TDT row id (not the Azure subscription GUID).
Workspaces under a Git-synced repo at azure/subscription-<guid>/<region>/<stack>
auto-link to the matching subscription by GUID on import — no manual picker
needed if the subscription is already registered.
Set state_storage_account + state_container to enable Azure Blob as a
Terraform state backend for workspaces flagged state_backend=azureblob (state
is written via the same SP over AAD — grant it Storage Blob Data Contributor).
GCP Projects — /api/v1/gcp-projects¶
Encrypted-at-rest GCP service-account keys, mirroring AWS Accounts / Azure
Subscriptions. Workspaces that target the google provider link one of these;
the executor writes the SA-key JSON to a 0600 file and exports
GOOGLE_APPLICATION_CREDENTIALS / GOOGLE_PROJECT / GOOGLE_REGION at run time.
| Method | Path | Description | Min role | BU |
|---|---|---|---|---|
| GET | /gcp-projects |
List configured projects (SA key never returned; email shown). | viewer | BU-scoped |
| POST | /gcp-projects |
Add a project (SA-key JSON stored encrypted). | admin | BU-scoped |
| PUT | /gcp-projects/{project_pk} |
Update name/description/region/state bucket or rotate the SA key. | admin | — |
| DELETE | /gcp-projects/{project_pk} |
Delete a project row. | admin | — |
| POST | /gcp-projects/{project_pk}/test |
Validate the SA key by minting an access token (google-auth). | admin | — |
| POST | /gcp-projects/{project_pk}/bucket |
Create (or verify) the GCS state bucket using the SA key. | admin | — |
POST /gcp-projects body: {project_id, name, description?, default_region?,
state_bucket?, state_prefix?, service_account_json}. The JSON is validated
structurally (must be a service_account key) and its embedded project_id
must match — mismatch → 422. project_id is unique per BU — duplicate → 409.
Set state_bucket to enable GCS as a Terraform state backend for workspaces
flagged state_backend=gcs. Workspaces at gcp/project-<id>/<region>/<stack>
auto-link to the matching project on import.
Kubernetes Clusters — /api/v1/clusters¶
Encrypted-at-rest kubeconfigs for Helm-kind workspaces
(workspace.kind=helm). Mirrors AWS Accounts: BU-scoped CRUD +
connectivity test. The kubeconfig is never returned or logged — responses
carry only a masked tail.
| Method | Path | Description | Min role | BU |
|---|---|---|---|---|
| GET | /clusters |
List clusters (masked). | viewer | BU-scoped |
| POST | /clusters |
Register a cluster (kubeconfig stored encrypted). | admin | BU-scoped |
| PUT | /clusters/{cluster_id} |
Update name/description/namespace, relink AWS account, or rotate the kubeconfig. | admin | BU-scoped |
| DELETE | /clusters/{cluster_id} |
Delete a cluster row. | admin | BU-scoped |
| POST | /clusters/{cluster_id}/test |
Validate connectivity via kubectl version. |
admin | BU-scoped |
POST /clusters body: {name, description?, server_url?,
default_namespace?, aws_account_id?, kubeconfig}. aws_account_id is
optional — set it for EKS clusters whose kubeconfig authenticates via the
aws eks get-token exec plugin; the test/executor paths then export that
account's decrypted creds (+ its default_region) into the subprocess
environment. Leave null for non-EKS clusters.
POST .../test writes the decrypted kubeconfig to a private 0600 temp file,
runs kubectl version --request-timeout=8s, and always deletes the temp file
afterward — the kubeconfig itself is never echoed back in the result, only
{ok, detail?, context?} (the resolved kubectl config current-context).
Workspaces — /api/v1/workspaces¶
A workspace is one Terraform state (or one Helm release) plus its Git-tracked
source path. kind is terraform (default) or helm — Helm workspaces
target a cluster_id instead of an aws_account_id and skip the S3/HTTP
state backend entirely (Helm release state lives in-cluster).
state_backend (s3 default | azureblob | gcs) selects where Terraform
state is stored. azureblob requires a linked azure_subscription_id whose
state_storage_account/state_container are set; gcs requires a linked
gcp_project_id whose state_bucket is set — create/update 422 otherwise.
gcp_project_id links the workspace to a GCP project (google provider), the
mirror of azure_subscription_id.
tags is a free-form key/value map ({"team": "payments", "tier": "prod"}).
Keys are lowercased on write — Team and team are the same tag — while values
keep their case. A workspace carries at most 32; keys are 1–64 chars matching
[a-z0-9][a-z0-9._-]*[a-z0-9], values up to 255. Responses always return an
object, never null.
| Method | Path | Description | Min role | BU |
|---|---|---|---|---|
| GET | /workspaces |
List workspaces. ?tag=team=payments filters by tag — repeatable and AND-ed; a bare ?tag=owner matches any value. |
viewer | BU-scoped |
| GET | /workspaces/tags |
Every tag key in the BU with its values and usage counts. | viewer | BU-scoped |
| POST | /workspaces/tags |
Bulk set/unset tags across many workspaces. | operator | BU-scoped |
| GET | /workspaces/{id} |
Get a single workspace. | viewer | BU-scoped |
| POST | /workspaces |
Create a workspace (manual). | admin | BU-scoped |
| PUT | /workspaces/{id} |
Update workspace (branch override, drift settings, state_aws_account_id, azure_subscription_id, gcp_project_id, state_backend, …). |
admin-tier key or interactive operator+ | BU-scoped |
| POST | /workspaces/discover |
Enumerate importable paths in a Git repo or local mount. | admin | BU-scoped |
| POST | /workspaces/import |
Bulk-import workspaces from a discovery result. | admin | BU-scoped |
| GET | /workspaces/{id}/branches |
List GitHub branches for the workspace's repo (falls back to free text if no token / non-GitHub remote). | viewer | BU-scoped |
| POST | /workspaces/{id}/sync |
Re-check a single workspace's path against its tracked ref now. | admin | BU-scoped |
| POST | /workspaces/sync |
Re-check every workspace's path in the current BU. | admin | BU-scoped |
| GET | /workspaces/{id}/state-lock |
Report whether a terraform state lock is currently held, and by which run. | viewer | BU-scoped |
| DELETE | /workspaces/{id}/state-lock |
Force-release a stuck state lock. Audited (workspace.force_unlock). |
admin-tier key or interactive operator+ | BU-scoped |
| DELETE | /workspaces/{id} |
Delete a workspace. Rejects git-synced ones with 409 unless ?force=true. With ?delete_state=true, also removes the S3 tfstate (default: retain). |
admin | BU-scoped |
Note on PUT and the state-lock DELETE: the route itself only requires
operator+ for interactive JWT callers, but both are additionally gated via
api_key_service.enforce(..., need="admin", ...), which is a no-op for JWT
callers and requires the admin capability tier for API-key callers. In
practice: any interactive operator/admin can call them; an automation token
needs the admin tier.
POST /workspaces — body¶
{
"name": "network-vpc",
"environment": "prod",
"aws_account_id": "111111111111", // required unless kind="helm"
"region": "us-east-1",
"repo_url": "https://github.com/example-org/infra.git", // optional; local:// or omit for manual
"tf_working_dir": ".",
"repo_ref": "main",
"webhook_enabled": false,
"kind": "terraform", // "terraform" | "helm"
"cluster_id": null, // required for kind="helm"
"azure_subscription_id": null // optional Azure link
}
kind="helm", cluster_id must reference a cluster in the same BU (400
otherwise) and aws_account_id defaults to the "global" sentinel if
omitted. For kind="terraform", aws_account_id is required and must
reference an AWS account already registered in the same BU (400 otherwise).
An optional azure_subscription_id must belong to the same BU if set (400
otherwise).
A workspace is unique per BU on (aws_account_id, region, environment,
tf_working_dir) — violating that identity tuple on create or update returns
a clean 409, not a raw 500.
POST /workspaces/discover — body¶
{
"repo_url": "https://github.com/example-org/infra.git", // or local_path (dev-only)
"ref": "main",
"username": "x-access-token", // optional; falls back to configured GitHub PAT
"token": "ghp_..." // optional; falls back to Settings → GitHub
}
{repo_url, ref, accounts: [{aws_account_id, regions: {<region>:
[{path, name, aws_account_id, region, suggested_environment, has_tf, kind,
already_imported}]}}], stack_count, errors}. Each leaf folder containing
*.tf (or a Helm Chart.yaml) becomes a candidate. already_imported flags
paths that are already workspaces in the current BU so the UI can gray them
out.
POST /workspaces/import — body¶
{
"repo_url": "https://github.com/example-org/infra.git",
"ref": "main",
"entries": [
{"path": "account-111111111111/us-east-1/network/prod", "name": "network",
"aws_account_id": "111111111111", "region": "us-east-1", "environment": "prod",
"kind": "terraform", "cluster_id": null}
]
}
{created: [WorkspaceResponse, ...], skipped: [{path, reason}, ...]}.
Duplicates (same path within the BU) are skipped, not errored. Azure leaves
at azure/subscription-<guid>/… auto-link to a matching subscription in the
BU by GUID; an unmatched GUID just leaves the workspace unlinked.
Orphan handling — WorkspaceResponse.path_status is ok / orphaned /
unknown. A background loop in the API process (default 10 min,
configurable via repo_sync.poll_seconds in Runtime Config) shallow-clones
each workspace's tracked ref and flips the status to orphaned when
tf_working_dir is missing. The UI shows an amber badge and replaces
Run/Destroy with a "Force delete" button on those rows. ?force=true plus
optional ?delete_state=true on the DELETE is the path for cleaning up the
TDT row when the source path was renamed/removed; the actual cloud resources
are presumed to have been migrated to the new workspace (or destroyed by
hand) — there is no fallback that runs a real terraform destroy on an orphan.
A force-delete of a git-synced workspace is audited as
workspace.force_delete.
State-backend cred override (state_aws_account_id) — optional column on
Workspace. aws_account_id always means "the AWS account whose resources
this workspace manages" and is what the dashboard groups by; it's also what
the executor uses by default to look up per-account creds for
AWS_ACCESS_KEY_ID. For non-AWS workspaces (aws_account_id="global") whose
terraform state nonetheless lives in an AWS S3 bucket owned by a different
account, set state_aws_account_id to that account's id — the executor uses
THAT account's per-account creds for the state backend while the workspace
stays grouped under "global" in the tree. PUT /workspaces/{id} accepts the
field; the new value (when non-empty) must be a registered AWS account in the
workspace's BU or the call 422s. Pass empty string to clear back to "same
as aws_account_id".
PUT /workspaces/{id} — passing {"repo_ref": "feat/x"} changes the
tracked branch atomically. Useful for the branch_only flow. azure_subscription_id
follows the same empty-string-clears / value-relinks convention as
state_aws_account_id.
State lock inspection/release — GET .../state-lock → {held, run_id?,
acquired_at?}. DELETE .../state-lock force-releases a Postgres advisory
lock stuck from a crashed executor; use only when certain no executor is
actually running against the workspace, since releasing a live lock could let
a second concurrent run race state.
POST /workspaces/tags — body¶
{
"workspace_ids": ["<ws-id>", "<ws-id>"],
"set": { "team": "payments", "tier": "prod" }, // merged per key
"unset": ["deprecated"] // removed if present
}
→ { "updated": 2, "workspaces": [ …full workspace objects… ] }
set merges rather than replacing the map, so retagging twenty workspaces'
team cannot wipe the owner tag one of them happens to carry. To replace a
single workspace's tags wholesale, PUT /workspaces/{id} with a tags object.
The batch is all-or-nothing: any id outside the current BU fails the whole
request with 404 rather than tagging a subset, because a partially-applied bulk
edit leaves you unable to tell which half landed. 400 if neither set nor
unset is given, or if a key or value fails validation.
Each affected workspace gets its own AuditLog row (workspace.tags_bulk_edit),
so "what happened to workspace X?" stays answerable.
GET /workspaces/tags¶
[ { "key": "team", "count": 3,
"values": [ {"value": "payments", "count": 2}, {"value": "ops", "count": 1} ] } ]
Keys sorted alphabetically, values by descending usage. Feeds filter autocomplete, and makes it obvious when two keys have drifted apart.
Workspace variables — /api/v1/workspaces/{id}/variables¶
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | …/variables |
List workspace-scoped variables (secrets masked). | viewer |
| POST | …/variables |
Create a workspace variable. 409 if the key already exists on this workspace. | operator |
| PATCH | …/variables/{var_id} |
Update a workspace variable. | operator |
| DELETE | …/variables/{var_id} |
Delete a workspace variable. | operator |
Global variables — /api/v1/variables¶
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /variables |
List global variables (BU-scoped; secrets masked). | viewer |
| POST | /variables |
Create a global variable. 409 if the key already exists. | admin |
| PATCH | /variables/{var_id} |
Update. | admin |
| DELETE | /variables/{var_id} |
Delete. | admin |
Both routers share the same request/response shape (VariableCreate /
VariableUpdate / VariableResponse):
// create
{"key": "instance_type", "value": "t3.medium", "is_secret": false, "is_hcl": false, "description": "..."}
key must match ^[A-Za-z_][A-Za-z0-9_]*$ (Terraform identifier rules) — a
mismatch is a 422 before anything is encrypted or written. key is immutable
after creation (delete + recreate to rename). Response: non-secret rows
return the real value; secret rows always return value: null plus
masked_tail (e.g. …ab12) so the UI can confirm which secret is configured
without ever re-exposing it. PATCH with value omitted leaves the stored
ciphertext untouched — use it to rotate description/is_secret without
re-supplying the value.
Variable precedence at run time: global ← workspace ← run (last wins
per key).
Runs — /api/v1/runs (+ /api/v1/workspaces/{id}/runs)¶
| Method | Path | Description | Min role |
|---|---|---|---|
| POST | /workspaces/{id}/runs |
Trigger a run (plan/apply/destroy). | operator (+ plan or apply API-key tier) |
| GET | /runs |
List runs, newest first (BU-scoped; API-key callers additionally narrowed to their workspace allowlist). Filters: ?workspace_id=, ?status= (comma-separated, e.g. failed,cancelled; an unknown value is a 400 listing the valid ones), ?limit= (1–1000). No default cap — the UI fetches the full list and buckets it client-side. |
viewer |
| GET | /runs/{run_id} |
Get a run. | viewer |
| PATCH | /runs/{run_id} |
Executor callbacks — status, plan output, plan_json, tfplan_b64, policy_status. Triggers notifications + auto-approve gate on awaiting_approval. |
operator |
| POST | /runs/{run_id}/heartbeat |
Executor liveness ping (run_jobs.heartbeat_at). 204, or 404 if there's no picked job for this run. | operator |
| POST | /runs/{run_id}/cancel |
Cancel a run that hasn't reached applying yet. |
operator (+ plan API-key tier) |
| GET | /runs/{run_id}/plan |
Raw plan output: {plan_output}. |
viewer (+ read API-key tier) |
| GET | /runs/{run_id}/tfplan |
Base64-encoded tfplan binary (executor uses this on apply): {tfplan_b64}. |
operator |
| GET | /runs/{run_id}/graph |
Structured {nodes, edges, summary} parsed from plan_json. |
viewer (+ read API-key tier) |
| GET | /runs/{run_id}/steps |
Run timeline (Init / Checkov / Plan / etc). ?since=<position> returns only steps at or after that index, and ?include_output=false omits each step's log blob — together they make polling a long apply cheap, since the finished prefix carries the bulk of the bytes. |
viewer (+ read API-key tier) |
| PATCH | /runs/{run_id}/steps/{step_id} |
Executor callback to update a single step. | operator |
| GET | /runs/{run_id}/policies |
OPA policy bundle + gate config the executor should enforce for this run. BU is derived from the run's workspace. | operator |
POST /workspaces/{id}/runs — body¶
{
"command": "plan" | "apply" | "destroy", // default "plan"
"variables": [{"key": "...", "value": "...", "is_secret": false, "is_hcl": false}], // per-run additions, optional
"branch": "feat/x", // optional override, persisted to workspace.repo_ref
"auto_approve_if_no_changes": false, // see auto-approve section
"auto_approve_skip_apply": false
}
RunResponse: {id, workspace_id, triggered_by, reviewer_id,
command, status, branch, plan_output, error_output, policy_status,
created_at, started_at, completed_at, auto_approve_if_no_changes,
auto_approve_skip_apply}.
Run status machine¶
pending → planning → planned → awaiting_approval → applying → applied, with
failed / cancelled reachable from most non-terminal states. Notable
guardrails enforced by PATCH /runs/{run_id}:
- A PATCH cannot move a run directly into applying — only POST
/runs/{id}/approve may do that.
- applied is only accepted immediately after applying.
- A PATCH that repeats the run's current status is a no-op (idempotent — lets
a reconnecting executor retry a heartbeat-adjacent status write safely).
- Cancellable source states: pending, running, planning, planned,
awaiting_approval. Once applying, a run must complete or fail — it can
no longer be cancelled (409 on POST /runs/{run_id}/cancel).
Auto-approve flow¶
When auto_approve_if_no_changes is true on an apply/destroy run AND
the plan succeeds with Checkov + Cost gates green AND
plan_json.resource_changes summary is 0/0/0, the API:
- Posts a synthetic system approval (audit
action="auto_approve",user_id=null, details include the plan summary). - If
auto_approve_skip_applyistrue, also writesaction="auto_apply_skipped"and transitionsawaiting_approval → applying → appliedwithout spawning an executor. - Otherwise enqueues an
applyjob exactly like a human approval would. - Posts a Slack notification if the BU has Slack integration configured.
The flag is silently ignored when command="plan" (no apply phase to
approve). All four Slack notification kinds (auto-approved, awaiting
approval, run failed, drift detected) require a configured Slack integration
on the target workspace's BU — failures are best-effort and never roll back
the run state machine.
Approvals — /api/v1/runs/{run_id} (approve/reject)¶
| Method | Path | Description | Min role |
|---|---|---|---|
| POST | /runs/{run_id}/approve |
Approve a run awaiting approval; queues the apply phase. | operator (+ apply API-key tier) |
| POST | /runs/{run_id}/reject |
Reject a run awaiting approval; transitions to cancelled. | operator (+ apply API-key tier) |
Body (optional): {"comment": "...optional..."}. 4-eyes approval was
revoked — the triggering user may also approve their own run. Any operator+
in the run's BU can approve or reject any run in that BU.
Drift — /api/v1/drift¶
Per-workspace and per-BU drift summaries, plus report ingestion from the drift-detector service.
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /drift/summary |
Per-BU drift breakdown: latest report per workspace, aggregated. | viewer |
| GET | /drift/{workspace_id} |
Latest drift report for one workspace, with per-resource detail. | viewer |
| POST | /drift/{workspace_id}/scan |
Queue an on-demand drift scan; returns a placeholder report id immediately (the real detector runs async). | admin |
| POST | /drift/{workspace_id}/report |
Submit drift scan results (detector or manual/test). | admin |
Reports are also accepted at the internal, state-token-authenticated path
POST /api/v1/internal/drift/{workspace_id}/report (used by the real
drift-detector service) and trigger a Slack alert whenever has_drift=true.
Either path additionally refreshes the cloud-asset Inventory when the report
includes an assets[] payload.
POST /drift/{workspace_id}/report body (DriftReportIn):
{
"workspace_id": "<id>",
"has_drift": true,
"summary": "3 resources modified out of band",
"plan_output": "...",
"modified_count": 3, "untracked_count": 0, "deleted_count": 0, "mismatch_count": 0,
"resources": [{"address": "aws_s3_bucket.logs", "type": "aws_s3_bucket", "provider": "aws", "drift_type": "modified", "summary": "..."}],
"assets": [] // optional; see Inventory
}
drift_type is one of modified | untracked | deleted | mismatch.
Posting a report also updates workspace.drift_status to drifted or
clean.
Inventory — /api/v1/inventory¶
Firefly-style cloud asset inventory: a read-side view of every discovered
cloud resource and how well Infrastructure-as-Code tracks it. Backed by the
cloud_assets table, which the drift-detector refreshes on every scan via
the assets[] field of a drift report.
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /inventory/summary |
Headline KPIs: codification %, per-iac_status counts, filter facets. |
viewer |
| GET | /inventory/assets |
Filterable, paginated asset list. | viewer |
| GET | /inventory/ignore-rules |
List the current BU's ignore rules. | viewer |
| POST | /inventory/ignore-rules |
Add an ignore rule; immediately reclassifies matching assets to ignored. |
admin |
| DELETE | /inventory/ignore-rules/{rule_id} |
Delete an ignore rule (suppressed assets revert on the next scan). | admin |
iac_status is one of codified | drifted | ghost | unmanaged |
ignored | undetermined. Codification % = tracked-by-IaC ÷ (total minus
ignored/service_managed), rounded to the nearest integer.
GET /inventory/summary query params (all optional, apply to the
counts/codification but not to the facets, which stay BU-global so filter
dropdowns never empty out): provider, region, account_id, search
(substring match on asset id/address).
GET /inventory/assets — same scope filters plus iac_status,
asset_type, and pagination: limit (default 200, max 1000), offset
(default 0). Returns {total, items: [...]}.
POST /inventory/ignore-rules body: {match_type: "arn_glob" |
"asset_type", pattern, note?}. Requires a concrete X-Business-Unit — 400
if scoped to "all".
Audit — /api/v1/audit¶
Tamper-evident hash-chained log of every write in the system.
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /audit/verify |
Re-walk the audit hash chain and report integrity. | admin |
| GET | /audit |
List audit entries, optionally filtered by run_id and/or workspace_id. |
admin |
GET /audit/verify query: limit (optional, 1-100000). Returns {ok:
true, total: N, broken_at: []} on a clean chain, or the first few offending
row ids if a stored hash fails to reproduce — that's the breakpoint for
forensic review.
GET /audit — cross-BU (no X-Business-Unit filtering is applied); the
only supported filters today are run_id and workspace_id query params.
Response: {items: [{id, user_id, action, resource_type, resource_id,
workspace_id, details, created_at}, ...]}.
Presence — /api/v1/presence¶
Cross-BU "who's online" indicator for the top bar. The UI pings every ~30s with the BU slug it currently has selected; the read side returns everyone seen in the last 60 seconds, deliberately across every BU — the entire point is letting an operator in one BU notice someone deploying in another and avoid a concurrent-change collision. Only identity (email, display name, selected BU slug) is exposed — never page contents or run state.
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /presence |
Upsert the caller's presence row. Body: {bu_slug?}. 204. |
any authenticated user |
| GET | /presence |
List users active in the last 60s, across all BUs. | any authenticated user |
Presence rows older than ~10× the window are lazily garbage-collected on each GET.
Environments — /api/v1/environments¶
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /environments |
List every workspace grouped by environment stage (dev/staging/prod), plus the promotion order. Cross-BU — no X-Business-Unit filtering. |
viewer |
| POST | /environments/{workspace_id}/promote |
Promote a workspace's config to the next environment stage and trigger a plan run there. | admin |
Promotion chain is fixed: dev → staging → prod. 400 if the source
workspace's environment isn't in the chain; 409 if it's already at prod
(the final stage). Promoting creates (or reuses, if already present) a
sibling workspace with the same name in the next stage, copying repo_url,
repo_ref, tf_working_dir, kind, cluster_id, azure_subscription_id,
and state_aws_account_id — but deliberately not state_key,
webhook_enabled, or any drift/path status, so the new environment gets its
own state path and starts with webhooks off and no scan history.
Integrations — /api/v1/integrations¶
Per-BU third-party credentials and pipeline-gate configuration, stored in the
encrypted config table. All admin-write / viewer-or-admin-read, BU-scoped
via X-Business-Unit (every route 400s without a concrete BU). Most
sub-resources fall back to a legacy global (non-BU) config key for one
release if the BU hasn't saved its own value yet — the response's inherited
flag tells the UI which case it's in.
GitHub¶
| Method | Path | Description |
|---|---|---|
| GET | /integrations/github |
{configured, token_tail, overridden_by_env, inherited} |
| PUT | /integrations/github |
Set GitHub PAT. Body: {"token": "ghp_…"}. |
| DELETE | /integrations/github |
Remove the BU's saved token. |
| POST | /integrations/github/test |
Probe the token against /user; returns {ok, login, scopes}. |
GitHub webhook (per-BU secret + org binding)¶
| Method | Path | Description |
|---|---|---|
| GET | /integrations/webhook |
{bu_slug, configured, secret_tail, github_org, webhook_path} |
| PUT | /integrations/webhook |
Body: {secret?, github_org?}. |
Infracost (cost estimation)¶
| Method | Path | Description |
|---|---|---|
| GET | /integrations/infracost |
{configured, api_key_tail, currency, overridden_by_env, inherited} |
| PUT | /integrations/infracost |
Body: {api_key?, currency?}. Empty-string api_key removes it. |
| POST | /integrations/infracost/test |
Pings the Infracost pricing GraphQL endpoint; returns {ok, detail?, organization?}. |
Checkov gate¶
| Method | Path | Description |
|---|---|---|
| GET | /integrations/checkov |
{mode: "fail"\|"warn", inherited} |
| PUT | /integrations/checkov |
Body: {mode}. |
OPA policy gate¶
| Method | Path | Description |
|---|---|---|
| GET | /integrations/opa |
{mode: "enforce"\|"warn"\|"off", use_bundled, bundled_severity, git_severity, repo_url, repo_ref, repo_dir, inherited} |
| PUT | /integrations/opa |
Body: same shape (admin). mode defaults to off. |
Terraform modules registry¶
| Method | Path | Description |
|---|---|---|
| GET | /integrations/modules |
{mode: "github"\|"local", upstream_url, local_host_dir, inherited} |
| PUT | /integrations/modules |
Body: same shape. local mode requires non-empty local_host_dir (422 otherwise). |
Default infra repo (Git-import prefill)¶
| Method | Path | Description |
|---|---|---|
| GET | /integrations/infra-repo |
{repo_url, inherited} — not a secret, just a UI convenience default for the Discover form. |
| PUT | /integrations/infra-repo |
Body: {repo_url}. |
Changelog (GitHub Releases / merged PRs feed)¶
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /integrations/changelog |
{repo, configured, inherited} — the owner/repo the Changelog tab reads from. |
viewer |
| PUT | /integrations/changelog |
Body: {repo} in owner/repo form (422 on a malformed value). |
admin |
| GET | /integrations/changelog/entries |
Stored changelog entries for the current BU, newest first. Reads only TDT's own DB — never hits GitHub. | viewer |
| POST | /integrations/changelog/sync |
Pull merged PRs from the configured repo, upserting by PR number. 404 if no repo configured; 502 on a GitHub-side failure. | admin |
| POST | /integrations/changelog/entries |
Add a manual changelog entry. Body: {title, body?, url?, entry_date?}. |
admin |
| DELETE | /integrations/changelog/entries/{entry_id} |
Delete one entry (manual or synced). | admin |
Synced entries have source="github" and ref=<PR number>; manual entries
have source="manual" and ref=null.
Slack (bot token + channel)¶
| Method | Path | Description |
|---|---|---|
| GET | /integrations/slack |
{configured, token_tail, team_name, channel_id, channel_name} |
| PUT | /integrations/slack |
Body: {token?, channel_id?, channel_name?}. Verifies the token via Slack auth.test before persisting; 422 if no token is saved yet and none is supplied. |
| DELETE | /integrations/slack |
Remove the bot token + channel. |
| POST | /integrations/slack/test |
Re-verify the saved bot token. Returns {ok, team, bot_user_id, url}. |
| GET | /integrations/slack/channels |
List the channels the bot can see (conversations.list). Each row has an is_private flag so the Settings UI can render a lock badge. |
The bot needs chat:write, plus channels:read to surface public
channels and groups:read to surface private ones. The bot must be
invited to the target channel before posts will land.
Notifications post on:
- Auto-approve fired (run was auto-approved on a 0/0/0 plan).
- Run awaiting approval (plan paused for human review).
- Run failed (plan / checkov / apply terminal failure).
- Drift detected (drift report transitioned a workspace to
drifted).
Webhooks — /api/v1/webhooks (public; HMAC-authenticated)¶
| Method | Path | Description |
|---|---|---|
| POST | /webhooks/forgejo |
Forgejo push hook. HMAC-SHA256 via X-Gitea-Signature, validated against the legacy global webhook.secret. Maps branch → environment (main/master/production→prod, staging/stage→staging, else dev) to disambiguate same-named workspaces across environments, then triggers a plan run if webhook_enabled and the branch matches the workspace's tracked ref. |
| POST | /webhooks/github |
Legacy global GitHub push hook (back-compat). HMAC-SHA256 via X-Hub-Signature-256. Matches every workspace whose repo_url references the pushed repo, filters by webhook_enabled + branch match + (if the payload lists changed files) whether the push touched that workspace's tf_working_dir, and triggers one plan run per remaining match. |
| POST | /webhooks/github/{bu_slug} |
Per-BU GitHub push hook. Preferred over the legacy path — validates against bu.<slug>.webhook.secret (falling back to the legacy global secret for one release) and only matches workspaces belonging to that BU, so a repo-name collision across BUs can't cross-trigger. |
All three always return 202 with a JSON body describing what happened
({status: "accepted"|"ignored", ...}) — a webhook sender never sees a 4xx
for "no matching workspace", only for a bad/missing signature (403) or
malformed JSON (400). Webhooks only ever create plan runs; applying still
requires a human (or auto-approve) approval.
Internal — /api/v1/internal (state-token-auth)¶
Used by background services (drift-detector, liveness-detector) that run on
the same private network as the API and authenticate with the
X-Terraducktel-State-Token header instead of a JWT.
| Method | Path | Description |
|---|---|---|
| GET | /internal/workspaces |
List every workspace, cross-BU. |
| POST | /internal/drift/{workspace_id}/report |
Drift report submission — identical body/behavior to the user-facing POST /drift/{workspace_id}/report, including the Slack drift alert and Inventory refresh. |
| GET | /internal/workspaces/{workspace_id}/aws-credentials |
Decrypted AWS creds for a workspace: {access_key_id, secret_access_key, account_id, region}. Honors the state_aws_account_id override, falling back to aws_account_id. Empty strings if the account has no stored credentials. |
| GET | /internal/github-token |
Plaintext GitHub token for in-network crons that can't decrypt the config table themselves: {token, source: "env"\|"config"\|"none"}. |
| POST | /internal/workspaces/{workspace_id}/auto-delete |
Cleanup hook used by the liveness detector when a workspace's repo path disappears upstream. Body: {reason}. Deletes the workspace + its runs/drift reports/state locks and audits as auto_delete_orphan. Idempotent (204) on an already-missing workspace. |
Runtime config — /api/v1/runtime-config¶
Admin-tunable worker timings and poll intervals (JWT auth = a superadmin concept in practice, since it's admin+ everywhere and cross-cutting rather than BU-scoped).
| Method | Path | Description | Min role |
|---|---|---|---|
| GET | /runtime-config |
Every tunable with its {value, default}. |
admin |
| PUT | /runtime-config/{key} |
Update one tunable. Body: {"value": <number>}. 404 for an unknown key, 422 if the value fails validation (e.g. not > 0). |
admin |
Workers pick up a new value within ~60s (the same ConfigService TTL cache
used everywhere else) without a container restart. Known tunables include
repo_sync.poll_seconds, auth.access_token_expire_minutes,
auth.refresh_token_expire_hours — see app/services/runtime_settings.py
for the authoritative catalog and defaults.
Terraform state — /api/v1/state/{workspace_id} (state-token-auth)¶
Implements Terraform's HTTP state backend protocol. Not meant to be called directly — Terraform itself talks to this via the executor's generated backend config.
| Method | Path | Description |
|---|---|---|
| GET | /state/{workspace_id} |
Fetch the tfstate body for a workspace. 404 (not 200 + empty JSON) when no state has ever been written — matches the HTTP backend spec's "create on first write" semantics and avoids Terraform misreading an empty body as a corrupted state file. 503 on a real S3/connectivity failure (never silently treated as "no state", which would let apply recreate every existing resource). |
| POST | /state/{workspace_id} |
Upload an updated tfstate body. Runs a secret scanner over the JSON first; a suspected leaked secret is rejected with 422. |
| POST | /state/{workspace_id}/lock |
Acquire a Postgres advisory lock for this workspace (TF HTTP backend lock protocol). 409 with Terraform-shaped lock-info JSON if already held. |
| DELETE | /state/{workspace_id}/lock |
Release the advisory lock. Idempotent — releasing an already-unlocked workspace is 200, not an error; only a genuine holder-id mismatch is 409. |
State lives in S3 (LocalStack in dev) at
{tf_working_dir}/terraform.tfstate inside the bucket owned by the
workspace's (or its state_aws_account_id override's) AWS account. Locking
is pg_try_advisory_lock-based, not DynamoDB.
Operational endpoints (unversioned, no auth)¶
These sit outside /api/v1 and are for probes and scrapers, not clients.
| Method | Path | Description |
|---|---|---|
| GET | /health |
Liveness/readiness probe. Used by the compose healthcheck and the ECS target group. |
| GET | /metrics |
Prometheus text-format counters (request counts and latency sums). |
| GET | /server-info |
Build/version identification. |
They are deliberately unauthenticated so a load balancer can reach them before
any credential exists. Do not expose /metrics publicly without a proxy in
front of it.
Status codes used everywhere¶
200 / 201 / 204— success.202— accepted for async processing (webhooks, drift scan trigger).400— bad input (e.g. invalid Slack token, channel id missing, BU scoped to "all" where a concrete BU is required).401— JWT or API key missing/expired/revoked.403— role or API-key capability insufficient, or an API key stepping outside its workspace allowlist, or an identity endpoint rejecting an API key outright.404— resource not found (including cross-BU access, which 404s rather than 403s to avoid leaking another tenant's resource existence).409— state-machine conflict (e.g. approving/cancelling a run in the wrong state, deleting a git-synced workspace, duplicate account/subscription/workspace identity tuple, regenerating a revoked/expired API key).422— schema validation failed (including business-rule validation like an out-of-BU account reference).502— upstream integration unreachable (GitHub, Slack, Infracost).503— state backend (S3) unavailable.
Audit actions you'll see¶
login, workspace.create, workspace.update, workspace.delete,
workspace.force_delete, workspace.force_unlock, workspace.tags_bulk_edit,
auto_delete_orphan, run.trigger, approve, auto_approve,
auto_apply_skipped, reject, aws_account.create, aws_account.update,
integration.github.set, integration.slack.set, api_key.create,
api_key.regenerate, api_key.rotate, api_key.revoke, …
workspace.tags_bulk_edit writes one row per affected workspace rather than
one per batch, so a workspace's own audit trail shows every tag change that ever
touched it.