mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:02:43 +00:00
38f1200f26
Sprint 5 unified-master-audit closure. Pre-fix:
- api/openapi.yaml: 7,788 LOC of hand-authored spec.
- web/src/api/generated/: directory did NOT exist (the Phase-5
scaffolding never had its first generation run).
- scripts/ci-guards/openapi-codegen-drift.sh: skip-when-absent
(line 33-39 — informational scaffold).
- api/openapi.yaml info.version: '2.0.0', latest tag: v2.1.7
(a 7-version drift between spec and ship).
Net effect: every new route required three coordinated edits (Go
handler, openapi.yaml, frontend client.ts), payload-level breaking
changes shipped unnoticed, and downstream API client integration
cost was permanent.
Phase 1 fix (the audit's literal scope):
1. **Run Orval**, commit the generated tree. 316 files / ~1.8 MB
under web/src/api/generated/, tags-split layout (one directory
per OpenAPI tag), TanStack Query client mode. All output routes
through web/src/api/mutator.ts which delegates to the existing
fetchJSON in client.ts so auth/CSRF/401-event semantics stay
in one place.
2. **Fix two spec defects** the first orval run surfaced:
- YAML duplicate-key bug at L77-89 — SCEP's description was
misplaced under OIDC. Restored to its own tag entry.
- Missing #/components/schemas/Error referenced by three
operations. Aliased to the existing ErrorResponse schema.
3. **Flip the codegen-drift guard from skip-when-absent to
hard-gate.** A missing generated/ directory now fails the
build with an actionable restore command. The existing
regenerate-and-diff path stays as before.
4. **New openapi-version-tag-parity CI guard.** Asserts
openapi.yaml info.version equals the latest v* git tag. Falls
back to api.github.com when the local clone is shallow.
Bumped openapi.yaml info.version 2.0.0 → 2.1.7 in the same
commit so the new guard greens out.
5. **CI workflow** updated to fetch tags on the frontend job's
checkout so the parity guard reads them locally (the GH API
fallback still works but adds a network round-trip).
Verified locally:
- openapi-codegen-drift.sh: clean (re-generation produces
byte-identical tree to what's tracked).
- openapi-version-tag-parity.sh: clean (2.1.7 == v2.1.7).
- tsc --noEmit: exit 0 across the entire frontend (the
generated tree's responseType field threaded through the
mutator's CertctlFetchOptions cleanly).
- Existing Vitest suite: 141/141 pass on the three sampled
suites (AuthProvider + client + IssuerHierarchyPage).
Follow-on work (NOT in this commit):
- Per-consumer migration: pages flip from client.ts imports to
generated/ imports one at a time. Both styles share fetchJSON
semantics, so the migration is incremental.
- Server-side oapi-codegen handler stubs (Phase 2 from the
audit's fix language) — separate sprint.
Closes ARCH-001-A.
7803 lines
268 KiB
YAML
7803 lines
268 KiB
YAML
openapi: 3.1.0
|
|
info:
|
|
title: certctl API
|
|
description: |
|
|
Certificate lifecycle management platform API. Manages certificates, issuers,
|
|
deployment targets, agents, jobs, policies, profiles, teams, owners, agent groups,
|
|
audit events, notifications, and observability metrics.
|
|
|
|
All endpoints under `/api/v1/` require authentication by default (configurable via
|
|
`CERTCTL_AUTH_TYPE`). Use `Bearer {api_key}` in the Authorization header.
|
|
|
|
Paginated list endpoints accept `page` (default 1) and `per_page` (default 50, max 500)
|
|
query parameters and return a standard envelope with `data`, `total`, `page`, and `per_page`.
|
|
# ARCH-001-A closure (Sprint 5, 2026-05-16): info.version MUST track
|
|
# the latest `v*` git tag. The openapi-version-tag-parity.sh CI guard
|
|
# asserts this on every CI run. Bump in lockstep with the
|
|
# `git tag -a v* ...` command at release time.
|
|
version: 2.1.7
|
|
license:
|
|
name: BSL 1.1
|
|
url: https://github.com/certctl-io/certctl/blob/master/LICENSE
|
|
|
|
servers:
|
|
- url: https://localhost:8443
|
|
description: Docker Compose demo (self-signed cert; pin with ./deploy/test/certs/ca.crt)
|
|
|
|
security:
|
|
- bearerAuth: []
|
|
|
|
tags:
|
|
- name: Certificates
|
|
description: Certificate lifecycle — CRUD, versions, renewal, deployment, revocation
|
|
- name: CRL & OCSP
|
|
description: |
|
|
Certificate revocation list (RFC 5280) and OCSP responder (RFC 6960).
|
|
Served unauthenticated under `/.well-known/pki/*` (RFC 8615) so
|
|
relying parties can retrieve revocation status without a certctl
|
|
API key.
|
|
- name: Issuers
|
|
description: CA issuer connector management (Local CA, ACME, step-ca)
|
|
- name: Targets
|
|
description: Deployment target management (NGINX, Apache, HAProxy, F5, IIS)
|
|
- name: Agents
|
|
description: Agent registration, heartbeat, CSR submission, work polling
|
|
- name: Jobs
|
|
description: Job queue — issuance, renewal, deployment, validation
|
|
- name: Policies
|
|
description: Policy rules and violation tracking
|
|
- name: RenewalPolicies
|
|
description: Lifecycle renewal policies (distinct from compliance policy rules above)
|
|
- name: Profiles
|
|
description: Certificate enrollment profiles with crypto constraints
|
|
- name: Teams
|
|
description: Team management for ownership grouping
|
|
- name: Owners
|
|
description: Certificate owner management with email routing
|
|
- name: Agent Groups
|
|
description: Dynamic agent grouping by OS, architecture, IP CIDR, version
|
|
- name: Audit
|
|
description: Immutable audit trail
|
|
- name: Notifications
|
|
description: Notification events (expiration, renewal, deployment, revocation)
|
|
- name: Stats
|
|
description: Dashboard statistics and aggregations
|
|
- name: Metrics
|
|
description: System metrics (gauges, counters, uptime)
|
|
- name: Health
|
|
description: Health and readiness probes, auth info
|
|
- name: Discovery
|
|
description: Certificate discovery — filesystem scanning by agents and network TLS probing
|
|
- name: Network Scan
|
|
description: Network scan target management for active TLS certificate discovery
|
|
- name: Health Monitoring
|
|
description: Continuous TLS endpoint health checks with status tracking and probe history
|
|
- name: Digest
|
|
description: Scheduled certificate digest email notifications
|
|
- name: Verification
|
|
description: Post-deployment TLS endpoint fingerprint verification
|
|
- name: EST
|
|
description: Enrollment over Secure Transport (RFC 7030)
|
|
- name: SCEP
|
|
description: Simple Certificate Enrollment Protocol (RFC 8894)
|
|
- name: Sessions
|
|
description: |
|
|
Server-side session management. Phase 13 Sprint 13.4 (ARCH-H1
|
|
closure batch 1) — authored against the Phase 9 Sprint 11
|
|
sibling-file handlers at internal/api/handler/auth_session_oidc_sessions.go.
|
|
- name: OIDC
|
|
description: |
|
|
OIDC identity-provider configuration + group-mapping admin.
|
|
Phase 13 Sprint 13.4 — authored against the Phase 9 Sprint 11
|
|
sibling-file handlers at internal/api/handler/auth_session_oidc_crud.go +
|
|
the JWKS-status surface at internal/api/handler/auth_users.go.
|
|
|
|
paths:
|
|
# ─── Health & Auth ───────────────────────────────────────────────────
|
|
/health:
|
|
get:
|
|
tags: [Health]
|
|
summary: Health check
|
|
security: []
|
|
operationId: getHealth
|
|
responses:
|
|
"200":
|
|
description: Server is healthy
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: healthy
|
|
|
|
/ready:
|
|
get:
|
|
tags: [Health]
|
|
summary: Readiness check
|
|
security: []
|
|
operationId: getReady
|
|
responses:
|
|
"200":
|
|
description: Server is ready
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: ready
|
|
|
|
/api/v1/auth/info:
|
|
get:
|
|
tags: [Health]
|
|
summary: Auth configuration info
|
|
description: Returns auth mode. Served without auth so GUI can detect auth requirements before login.
|
|
security: []
|
|
operationId: getAuthInfo
|
|
responses:
|
|
"200":
|
|
description: Auth configuration
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
auth_type:
|
|
type: string
|
|
# G-1 (P1): "jwt" removed from this enum after the silent
|
|
# auth downgrade was identified — no JWT middleware ships
|
|
# with certctl. Operators who need JWT continue to front
|
|
# certctl with an authenticating gateway (oauth2-proxy /
|
|
# Envoy / Traefik / Pomerium) and set
|
|
# CERTCTL_AUTH_TYPE=none upstream. See
|
|
# docs/architecture.md "Authenticating-gateway pattern".
|
|
#
|
|
# Auth Bundle 2 Phase 0: "oidc" added to the enum. The
|
|
# session middleware + OIDC handler chain ship in later
|
|
# Bundle 2 phases; until they land, setting
|
|
# CERTCTL_AUTH_TYPE=oidc fails the runtime guard in
|
|
# cmd/server/main.go with an actionable error rather
|
|
# than silently falling back to api-key (the G-1
|
|
# failure mode). The literal is in the enum so the GUI
|
|
# Login page (Phase 8) can render OIDC provider
|
|
# buttons against an /auth/info response that reflects
|
|
# the configured auth_type.
|
|
enum: [api-key, none, oidc]
|
|
required:
|
|
type: boolean
|
|
|
|
/api/v1/auth/check:
|
|
get:
|
|
tags: [Health]
|
|
summary: Validate credentials
|
|
description: |
|
|
Returns 200 if auth credentials are valid, 401 otherwise.
|
|
|
|
Bundle 1 Phase 3 closure (M1): when the server has the RBAC
|
|
primitive wired (Bundle 1 default), the response also includes
|
|
the caller's `actor_id`, `actor_type`, `tenant_id`, the
|
|
`roles` they hold, and `effective_permissions` they resolve
|
|
to. The legacy `admin` boolean is preserved for back-compat
|
|
with pre-Bundle-1 GUIs; new GUIs should switch to
|
|
`effective_permissions` for affordance gating.
|
|
operationId: checkAuth
|
|
responses:
|
|
"200":
|
|
description: Authenticated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [status]
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: authenticated
|
|
user:
|
|
type: string
|
|
description: Named-key identity (empty when CERTCTL_AUTH_TYPE=none)
|
|
admin:
|
|
type: boolean
|
|
description: Legacy admin flag (back-compat with pre-Bundle-1 GUIs).
|
|
actor_id:
|
|
type: string
|
|
description: Actor identifier for the authenticated request (Bundle 1+).
|
|
actor_type:
|
|
type: string
|
|
enum: [User, System, Agent, APIKey, Anonymous]
|
|
description: Actor-type discriminator (Bundle 1+).
|
|
tenant_id:
|
|
type: string
|
|
description: Tenant the actor belongs to (Bundle 1 ships single-tenant `t-default`).
|
|
admin_via_role:
|
|
type: boolean
|
|
description: True when the actor holds `r-admin`. Authoritative admin signal under Bundle 1+.
|
|
roles:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Role IDs (e.g. `r-admin`, `r-viewer`) the actor holds.
|
|
effective_permissions:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [permission, scope_type]
|
|
properties:
|
|
permission:
|
|
type: string
|
|
example: cert.bulk_revoke
|
|
scope_type:
|
|
type: string
|
|
enum: [global, profile, issuer]
|
|
scope_id:
|
|
type: string
|
|
"401":
|
|
description: Unauthorized
|
|
|
|
# ─── Auth / RBAC (Bundle 1 Phase 4) ─────────────────────────────────
|
|
# The RBAC primitive surface for managing roles, permissions, and the
|
|
# role grants assigned to actors (API keys today; OIDC-federated users
|
|
# in Bundle 2). Every mutating route runs through the service layer's
|
|
# privilege-escalation guard — callers need `auth.role.assign` for
|
|
# role grants on actors, `auth.role.create/edit/delete` for the role
|
|
# lifecycle, `auth.key.*` for key management. Read endpoints require
|
|
# `auth.role.list`. The /v1/auth/me endpoint has no permission gate
|
|
# (every authenticated caller can read their own permissions).
|
|
/api/v1/auth/bootstrap:
|
|
get:
|
|
tags: [Auth]
|
|
summary: Probe whether the day-0 bootstrap endpoint is callable
|
|
description: |
|
|
Returns `{available: true}` when CERTCTL_BOOTSTRAP_TOKEN is set
|
|
AND no admin-roled actor exists yet; otherwise `{available: false}`.
|
|
Auth-exempt because it serves the GUI / install one-liner before
|
|
the first admin key has been minted. Bundle 1 Phase 6.
|
|
security: []
|
|
operationId: getAuthBootstrap
|
|
responses:
|
|
"200":
|
|
description: Bootstrap availability
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [available]
|
|
properties:
|
|
available:
|
|
type: boolean
|
|
post:
|
|
tags: [Auth]
|
|
summary: Mint the first admin API key from a one-shot bootstrap token
|
|
description: |
|
|
Operator POSTs the CERTCTL_BOOTSTRAP_TOKEN value plus the desired
|
|
admin-key name. Returns the freshly minted plaintext key value
|
|
once; the server stores only the SHA-256 hash. Subsequent calls
|
|
return 410 Gone (the strategy is one-shot AND the admin-existence
|
|
probe re-closes the door once the new admin lands). Auth-exempt
|
|
because the endpoint authenticates via the bootstrap token
|
|
itself. Bundle 1 Phase 6.
|
|
security: []
|
|
operationId: postAuthBootstrap
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [token, actor_name]
|
|
properties:
|
|
token:
|
|
type: string
|
|
description: The CERTCTL_BOOTSTRAP_TOKEN value (constant-time compared server-side).
|
|
actor_name:
|
|
type: string
|
|
description: 3-64 chars, lowercase alphanumeric + hyphen + underscore.
|
|
pattern: "^[a-z0-9][a-z0-9_-]{2,63}$"
|
|
responses:
|
|
"201":
|
|
description: Admin key minted
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [actor_id, api_key_id, key_value, created_at, message]
|
|
properties:
|
|
actor_id: { type: string }
|
|
api_key_id: { type: string }
|
|
key_value:
|
|
type: string
|
|
description: The plaintext API key. Capture this — it is shown only once.
|
|
created_at: { type: string, format: date-time }
|
|
message: { type: string }
|
|
"400": { description: Invalid actor_name or malformed body }
|
|
"401": { description: Bootstrap token mismatch }
|
|
"410":
|
|
description: |
|
|
Endpoint disabled. Either CERTCTL_BOOTSTRAP_TOKEN is unset,
|
|
an admin actor already exists, or the strategy was already
|
|
consumed by a successful prior call.
|
|
|
|
/api/v1/auth/me:
|
|
get:
|
|
tags: [Auth]
|
|
summary: Current actor's roles + effective permissions
|
|
description: |
|
|
Returns the standing roles + effective permission set for the
|
|
authenticated caller. This is the query the GUI uses to gate
|
|
affordance rendering; /api/v1/auth/check returns the same shape
|
|
on the boot path.
|
|
operationId: getAuthMe
|
|
responses:
|
|
"200":
|
|
description: Caller identity + roles + effective permissions
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [actor_id, actor_type, tenant_id, admin, roles, effective_permissions]
|
|
properties:
|
|
actor_id: { type: string }
|
|
actor_type: { type: string, enum: [User, System, Agent, APIKey, Anonymous] }
|
|
tenant_id: { type: string }
|
|
admin: { type: boolean }
|
|
roles:
|
|
type: array
|
|
items: { type: string }
|
|
effective_permissions:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [permission, scope_type]
|
|
properties:
|
|
permission: { type: string }
|
|
scope_type: { type: string, enum: [global, profile, issuer] }
|
|
scope_id: { type: string }
|
|
"401":
|
|
description: Unauthorized
|
|
|
|
/api/v1/auth/permissions:
|
|
get:
|
|
tags: [Auth]
|
|
summary: List canonical permission catalogue
|
|
description: |
|
|
Returns every permission name registered in the canonical
|
|
catalogue. Used by the GUI's role editor to populate the
|
|
"grant permission" picker. Permission: `auth.role.list`.
|
|
operationId: listAuthPermissions
|
|
responses:
|
|
"200":
|
|
description: Permission catalogue
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
permissions:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [id, name, namespace]
|
|
properties:
|
|
id: { type: string }
|
|
name: { type: string }
|
|
namespace: { type: string }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
|
|
/api/v1/auth/roles:
|
|
get:
|
|
tags: [Auth]
|
|
summary: List roles for the active tenant
|
|
description: Permission `auth.role.list`. Returns every role registered for `t-default` (Bundle 1 single-tenant).
|
|
operationId: listAuthRoles
|
|
responses:
|
|
"200":
|
|
description: Role list
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
roles:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/AuthRole" }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
post:
|
|
tags: [Auth]
|
|
summary: Create a custom role
|
|
description: Permission `auth.role.create`. Default roles (`r-admin` / `r-operator` / `r-viewer` / `r-agent` / `r-mcp` / `r-cli` / `r-auditor`) are seeded by migration and immutable.
|
|
operationId: createAuthRole
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name]
|
|
properties:
|
|
name: { type: string }
|
|
description: { type: string }
|
|
responses:
|
|
"201":
|
|
description: Role created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/AuthRole" }
|
|
"400": { description: Validation error }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"409": { description: Role with that name already exists }
|
|
|
|
/api/v1/auth/roles/{id}:
|
|
get:
|
|
tags: [Auth]
|
|
summary: Get a role and its permissions
|
|
description: Permission `auth.role.list`.
|
|
operationId: getAuthRole
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"200":
|
|
description: Role + permissions
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
role: { $ref: "#/components/schemas/AuthRole" }
|
|
permissions:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/AuthRolePermission" }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Role not found }
|
|
put:
|
|
tags: [Auth]
|
|
summary: Update a custom role's name or description
|
|
description: Permission `auth.role.edit`. Default roles cannot be renamed.
|
|
operationId: updateAuthRole
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
description: { type: string }
|
|
responses:
|
|
"200": { description: Updated }
|
|
"400": { description: Validation error }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Role not found }
|
|
"409": { description: Default role cannot be renamed / name collision }
|
|
delete:
|
|
tags: [Auth]
|
|
summary: Delete a custom role
|
|
description: Permission `auth.role.delete`. Fails with 409 when actors still hold the role (FK ON DELETE RESTRICT).
|
|
operationId: deleteAuthRole
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"204": { description: Deleted }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Role not found }
|
|
"409": { description: Role still has active actor assignments }
|
|
|
|
/api/v1/auth/roles/{id}/permissions:
|
|
post:
|
|
tags: [Auth]
|
|
summary: Grant a permission to a role at a scope
|
|
description: Permission `auth.role.edit`. ScopeType defaults to `global`; per-profile / per-issuer scopes require ScopeID.
|
|
operationId: grantAuthRolePermission
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [permission]
|
|
properties:
|
|
permission: { type: string }
|
|
scope_type:
|
|
type: string
|
|
enum: [global, profile, issuer]
|
|
default: global
|
|
scope_id: { type: string }
|
|
responses:
|
|
"204": { description: Granted }
|
|
"400": { description: Permission not in canonical catalogue / scope_id missing for non-global scope }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Role not found }
|
|
|
|
/api/v1/auth/roles/{id}/permissions/{perm}:
|
|
delete:
|
|
tags: [Auth]
|
|
summary: Revoke a permission from a role
|
|
description: Permission `auth.role.edit`.
|
|
operationId: revokeAuthRolePermission
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
- in: path
|
|
name: perm
|
|
required: true
|
|
schema: { type: string }
|
|
- in: query
|
|
name: scope_type
|
|
schema:
|
|
type: string
|
|
enum: [global, profile, issuer]
|
|
- in: query
|
|
name: scope_id
|
|
schema: { type: string }
|
|
responses:
|
|
"204": { description: Revoked }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Role or permission grant not found }
|
|
|
|
/api/v1/auth/keys:
|
|
get:
|
|
tags: [Auth]
|
|
summary: List actors with role grants in the active tenant
|
|
description: |
|
|
Returns every distinct (actor_id, actor_type) pair in the
|
|
tenant that holds at least one role grant. Bundle 1 Phase 7
|
|
ships this so the CLI's `auth keys list` and scope-down helper
|
|
can enumerate the operator-key population without joining
|
|
against the env-var-loaded namedKeys directly. Permission
|
|
`auth.role.list`.
|
|
operationId: listAuthKeys
|
|
responses:
|
|
"200":
|
|
description: Actor list with role assignments
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
keys:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [actor_id, actor_type, tenant_id, role_ids]
|
|
properties:
|
|
actor_id: { type: string }
|
|
actor_type:
|
|
type: string
|
|
enum: [User, System, Agent, APIKey, Anonymous]
|
|
tenant_id: { type: string }
|
|
role_ids:
|
|
type: array
|
|
items: { type: string }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
|
|
/api/v1/auth/keys/{id}/roles:
|
|
post:
|
|
tags: [Auth]
|
|
summary: Assign a role to an API key
|
|
description: Permission `auth.role.assign`. The reserved `actor-demo-anon` actor cannot be re-assigned.
|
|
operationId: assignAuthKeyRole
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [role_id]
|
|
properties:
|
|
role_id: { type: string }
|
|
responses:
|
|
"204": { description: Assigned }
|
|
"400": { description: Validation error }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Role not found }
|
|
"409": { description: Reserved system actor cannot be modified }
|
|
|
|
/api/v1/auth/keys/{id}/roles/{role_id}:
|
|
delete:
|
|
tags: [Auth]
|
|
summary: Revoke a role from an API key
|
|
description: Permission `auth.role.assign`. Revoking the synthetic `actor-demo-anon` admin grant is rejected.
|
|
operationId: revokeAuthKeyRole
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
- in: path
|
|
name: role_id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"204": { description: Revoked }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Role not assigned to actor }
|
|
"409": { description: Reserved system actor cannot be modified }
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 13 Sprint 13.4 (ARCH-H1 batch 1) — sessions + OIDC CRUD.
|
|
# Authored 2026-05-14 against the Phase 9 Sprint 11 handler
|
|
# sibling-files at internal/api/handler/auth_session_oidc_*.go.
|
|
# Each schema field-by-field mirrors the projection types
|
|
# (sessionResponse / oidcProviderResponse / oidcProviderRequest /
|
|
# groupMappingResponse / groupMappingRequest) in
|
|
# auth_session_oidc_{sessions,crud}.go.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
/api/v1/auth/sessions:
|
|
get:
|
|
tags: [Sessions]
|
|
summary: List active sessions (own actor by default; specify actor_id to list another actor's)
|
|
description: |
|
|
Permission `auth.session.list` for own-sessions. Listing another
|
|
actor's sessions additionally requires `auth.session.list.all`
|
|
(re-checked inline by the handler; the router-level rbacGate
|
|
cannot see the query parameter).
|
|
|
|
Audit 2026-05-10 MED-2 closure — the all-actors variant is an
|
|
admin-class capability, segregated from the same-actor floor.
|
|
operationId: listAuthSessions
|
|
parameters:
|
|
- in: query
|
|
name: actor_id
|
|
required: false
|
|
schema: { type: string }
|
|
description: Target actor whose sessions to list. Defaults to the calling actor.
|
|
- in: query
|
|
name: actor_type
|
|
required: false
|
|
schema: { type: string }
|
|
description: Required when `actor_id` is set and differs from the caller's type. Ignored otherwise.
|
|
responses:
|
|
"200":
|
|
description: Session list
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [sessions]
|
|
properties:
|
|
sessions:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/AuthSession"
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden (auth.session.list missing, or auth.session.list.all missing on cross-actor lookup) }
|
|
"500": { description: Internal error }
|
|
delete:
|
|
tags: [Sessions]
|
|
summary: Revoke all sessions for the caller except the current one
|
|
description: |
|
|
Permission `auth.session.revoke`. Revokes every active session
|
|
for the calling actor EXCEPT the session that issued this
|
|
request (so the user isn't logged out by the action they just
|
|
took). Bearer/API-key callers (whose request has no session
|
|
cookie) get all their sessions revoked.
|
|
|
|
Audit 2026-05-10 MED-3 closure — backs the SessionsPage's
|
|
"Sign out all other sessions" button.
|
|
|
|
Only the `?except=current` form is accepted; any other query
|
|
parameter combination returns 400.
|
|
operationId: revokeAuthSessionsExceptCurrent
|
|
parameters:
|
|
- in: query
|
|
name: except
|
|
required: true
|
|
schema: { type: string, enum: [current] }
|
|
description: Must be the literal string `current`.
|
|
responses:
|
|
"200":
|
|
description: Revoked count
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [revoked_count]
|
|
properties:
|
|
revoked_count:
|
|
type: integer
|
|
description: Number of sessions revoked (excludes the current session).
|
|
"400": { description: Missing or unsupported query parameters }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/sessions/{id}:
|
|
delete:
|
|
tags: [Sessions]
|
|
summary: Revoke a specific session by ID
|
|
description: |
|
|
Permission `auth.session.revoke`. Revoking your own session is
|
|
always allowed (any authenticated caller); revoking another
|
|
actor's session requires the same `auth.session.revoke`
|
|
permission enforced at the rbacGate.
|
|
operationId: revokeAuthSession
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
description: Session ID.
|
|
responses:
|
|
"204": { description: Revoked }
|
|
"400": { description: Missing session id }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Session not found }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/oidc/providers:
|
|
get:
|
|
tags: [OIDC]
|
|
summary: List configured OIDC identity providers
|
|
description: Permission `auth.oidc.list`. Returns provider rows for the calling actor's tenant.
|
|
operationId: listOIDCProviders
|
|
responses:
|
|
"200":
|
|
description: Provider list
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [providers]
|
|
properties:
|
|
providers:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/OIDCProviderResponse"
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"500": { description: Internal error }
|
|
post:
|
|
tags: [OIDC]
|
|
summary: Create an OIDC identity provider
|
|
description: |
|
|
Permission `auth.oidc.create`. `client_secret` is required +
|
|
encrypted at rest via the config-encryption key
|
|
(`CERTCTL_CONFIG_ENCRYPTION_KEY`). The provider is namespaced
|
|
by the caller's tenant.
|
|
operationId: createOIDCProvider
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/OIDCProviderRequest"
|
|
responses:
|
|
"201":
|
|
description: Provider created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/OIDCProviderResponse"
|
|
"400": { description: Validation error (missing client_secret, invalid JSON, etc.) }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"409": { description: Provider name already exists for this tenant }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/oidc/providers/{id}:
|
|
put:
|
|
tags: [OIDC]
|
|
summary: Update an OIDC identity provider's configuration
|
|
description: |
|
|
Permission `auth.oidc.edit`. Update is a full replacement: every
|
|
OIDCProviderRequest field is honored; `client_secret` is
|
|
re-encrypted on every update.
|
|
operationId: updateOIDCProvider
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/OIDCProviderRequest"
|
|
responses:
|
|
"200":
|
|
description: Provider updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/OIDCProviderResponse"
|
|
"400": { description: Validation error }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Provider not found }
|
|
"500": { description: Internal error }
|
|
delete:
|
|
tags: [OIDC]
|
|
summary: Delete an OIDC identity provider
|
|
description: |
|
|
Permission `auth.oidc.delete`. 409 Conflict is returned when the
|
|
provider has active group-mappings or live sessions referencing
|
|
it (the operator must remove the dependencies first).
|
|
operationId: deleteOIDCProvider
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"204": { description: Provider deleted }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Provider not found }
|
|
"409": { description: Provider in use (active group-mappings or sessions reference it) }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/oidc/providers/{id}/jwks-status:
|
|
get:
|
|
tags: [OIDC]
|
|
summary: Read per-provider JWKS health (cached keys, refresh count, last error)
|
|
description: |
|
|
Permission `auth.oidc.list`. Audit 2026-05-10 MED-7 — surfaces
|
|
the JWKS verifier state for the named provider so operators can
|
|
diagnose IdP key-rotation issues without server logs.
|
|
operationId: getOIDCProviderJWKSStatus
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"200":
|
|
description: JWKS health snapshot
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/OIDCJWKSStatusSnapshot"
|
|
"400": { description: Missing provider id }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Provider not found }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/oidc/providers/{id}/refresh:
|
|
post:
|
|
tags: [OIDC]
|
|
summary: Force re-fetch of the IdP discovery doc + JWKS for a provider
|
|
description: |
|
|
Permission `auth.oidc.edit`. Triggers an immediate refetch of
|
|
the named provider's OIDC discovery document + JWKS, re-runs
|
|
the IdP downgrade-attack defense (Audit 2026-05-10 HIGH-6),
|
|
and updates the in-memory verifier cache. Used by the
|
|
SessionsPage "Refresh JWKS" button when an operator rotates
|
|
IdP keys out-of-band.
|
|
operationId: refreshOIDCProvider
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"200":
|
|
description: Refresh complete
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [refreshed]
|
|
properties:
|
|
refreshed:
|
|
type: boolean
|
|
description: Always `true` on success.
|
|
"400": { description: Missing provider id, or upstream refresh failed }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Provider not found }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/oidc/test:
|
|
post:
|
|
tags: [OIDC]
|
|
summary: Dry-run an OIDC provider config without persisting
|
|
description: |
|
|
Permission `auth.oidc.create`. Audit 2026-05-10 MED-5 — fetches
|
|
the candidate issuer's discovery doc + JWKS, runs the
|
|
alg-downgrade defense, parses the RFC 9207 iss-parameter
|
|
advert, and returns the per-check report so the GUI can
|
|
render a discovery-validation panel before the operator
|
|
commits to creating the provider.
|
|
operationId: testOIDCProvider
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/OIDCTestRequest"
|
|
responses:
|
|
"200":
|
|
description: Discovery + JWKS test report (partial-success cases return 200 with non-empty `errors`)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/OIDCTestDiscoveryResult"
|
|
"400": { description: Missing issuer_url, or invalid JSON body }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"500": { description: Internal error (discovery test framework failure) }
|
|
|
|
/api/v1/auth/oidc/group-mappings:
|
|
get:
|
|
tags: [OIDC]
|
|
summary: List group → role mappings for a provider
|
|
description: Permission `auth.oidc.list`. `provider_id` query parameter is required.
|
|
operationId: listOIDCGroupMappings
|
|
parameters:
|
|
- in: query
|
|
name: provider_id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"200":
|
|
description: Group-mapping list
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [mappings]
|
|
properties:
|
|
mappings:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/OIDCGroupMappingResponse"
|
|
"400": { description: Missing provider_id }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"500": { description: Internal error }
|
|
post:
|
|
tags: [OIDC]
|
|
summary: Add a group → role mapping
|
|
description: Permission `auth.oidc.edit`. Establishes that members of `group_name` (as advertised by the IdP) receive the named role for the calling tenant.
|
|
operationId: addOIDCGroupMapping
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/OIDCGroupMappingRequest"
|
|
responses:
|
|
"201":
|
|
description: Mapping created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/OIDCGroupMappingResponse"
|
|
"400": { description: Validation error (invalid JSON or missing required fields) }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"409": { description: Mapping already exists (same provider_id + group_name) }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/oidc/group-mappings/{id}:
|
|
delete:
|
|
tags: [OIDC]
|
|
summary: Remove a group → role mapping
|
|
description: Permission `auth.oidc.edit`.
|
|
operationId: removeOIDCGroupMapping
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"204": { description: Mapping removed }
|
|
"400": { description: Missing mapping id }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Mapping not found }
|
|
"500": { description: Internal error }
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 13 Sprint 13.5 (ARCH-H1 batch 2) — break-glass admin, users
|
|
# admin, runtime-config read. Authored 2026-05-14 against the
|
|
# internal/api/handler/auth_breakglass.go + auth_users.go handlers.
|
|
# Surface-invisibility: every break-glass admin endpoint returns 404
|
|
# when CERTCTL_BREAKGLASS_ENABLED=false; documented per-op.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
/api/v1/auth/breakglass/credentials:
|
|
get:
|
|
tags: [Auth]
|
|
summary: List break-glass credentials (metadata only — never the password hash)
|
|
description: |
|
|
Permission `auth.breakglass.admin`. Audit 2026-05-10 CRIT-4
|
|
closure — backs the GUI Break-glass admin page. The password
|
|
hash is NEVER serialized to the wire; only the credential
|
|
metadata.
|
|
|
|
Returns 404 when `CERTCTL_BREAKGLASS_ENABLED=false` (surface-
|
|
invisibility: an attacker probing the admin surface gets the
|
|
same signal as probing the login endpoint).
|
|
operationId: listBreakglassCredentials
|
|
responses:
|
|
"200":
|
|
description: Credential list
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BreakglassCredentialListResponse"
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Break-glass disabled }
|
|
"500": { description: Internal error }
|
|
post:
|
|
tags: [Auth]
|
|
summary: Set a break-glass password for an actor
|
|
description: |
|
|
Permission `auth.breakglass.admin`. Creates or rotates a
|
|
break-glass credential for the named `actor_id`. Password
|
|
strength is validated by the service (min 12 bytes, max 256
|
|
bytes); weak passwords return 400.
|
|
|
|
Returns 404 when `CERTCTL_BREAKGLASS_ENABLED=false`.
|
|
operationId: setBreakglassPassword
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BreakglassSetPasswordRequest"
|
|
responses:
|
|
"201":
|
|
description: Credential created or rotated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BreakglassSetPasswordResponse"
|
|
"400": { description: Password fails strength requirements or invalid JSON body }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Break-glass disabled }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/breakglass/credentials/{actor_id}:
|
|
delete:
|
|
tags: [Auth]
|
|
summary: Remove a break-glass credential
|
|
description: |
|
|
Permission `auth.breakglass.admin`. Returns 404 when
|
|
`CERTCTL_BREAKGLASS_ENABLED=false` OR when the credential
|
|
doesn't exist.
|
|
operationId: removeBreakglassCredential
|
|
parameters:
|
|
- in: path
|
|
name: actor_id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"204": { description: Credential removed }
|
|
"400": { description: Missing actor_id path param }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Break-glass disabled OR credential not found }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/breakglass/credentials/{actor_id}/unlock:
|
|
post:
|
|
tags: [Auth]
|
|
summary: Clear the lockout on a break-glass credential
|
|
description: |
|
|
Permission `auth.breakglass.admin`. Resets the failure counter
|
|
and clears any active lockout on the named credential so the
|
|
actor can attempt to log in again after the configured
|
|
lockout window has elapsed organically OR an admin unblocks
|
|
them early.
|
|
|
|
Returns 404 when `CERTCTL_BREAKGLASS_ENABLED=false` OR when
|
|
the credential doesn't exist.
|
|
operationId: unlockBreakglassCredential
|
|
parameters:
|
|
- in: path
|
|
name: actor_id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"204": { description: Lockout cleared }
|
|
"400": { description: Missing actor_id path param }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: Break-glass disabled OR credential not found }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/users:
|
|
get:
|
|
tags: [Auth]
|
|
summary: List federated users for the active tenant
|
|
description: |
|
|
Permission `auth.user.read`. Audit 2026-05-10 MED-11 +
|
|
2026-05-11 A-2 — backs the admin GUI Users page. Pagination is
|
|
not server-side; the repository's `ListAll` returns every row
|
|
and the handler filters client-side. Optional
|
|
`oidc_provider_id` query parameter scopes the list to users
|
|
federated from one provider.
|
|
operationId: listAuthUsers
|
|
parameters:
|
|
- in: query
|
|
name: oidc_provider_id
|
|
required: false
|
|
schema: { type: string }
|
|
description: When set, only return users whose `oidc_provider_id` matches exactly.
|
|
responses:
|
|
"200":
|
|
description: User list
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [users]
|
|
properties:
|
|
users:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/AuthUser"
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/users/{id}:
|
|
delete:
|
|
tags: [Auth]
|
|
summary: Deactivate a user (sets deactivated_at + cascade-revokes active sessions)
|
|
description: |
|
|
Permission `auth.user.deactivate`. Audit 2026-05-11 A-2 — the
|
|
handler rejects self-deactivation with 409 to prevent the
|
|
"admin deactivates self, can't reactivate themselves" foot-gun
|
|
(break-glass remains the recovery path).
|
|
operationId: deactivateAuthUser
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"204": { description: User deactivated + active sessions revoked }
|
|
"400": { description: Missing user id }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: User not found }
|
|
"409": { description: Refused — caller is deactivating their own account (use break-glass recovery or have another admin act) }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/auth/users/{id}/reactivate:
|
|
post:
|
|
tags: [Auth]
|
|
summary: Reactivate a previously-deactivated user
|
|
description: |
|
|
Permission `auth.user.deactivate` (same gate as the inverse
|
|
op — reactivation is not a separate privilege). Idempotent:
|
|
reactivating an already-active user is a no-op (204).
|
|
operationId: reactivateAuthUser
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
"204": { description: User reactivated (or was already active — idempotent) }
|
|
"400": { description: Missing user id }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"404": { description: User not found }
|
|
"500": { description: Internal error }
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 13 Sprint 13.6 (ARCH-H1 batch 3 — the final batch).
|
|
# 4 one-off REST endpoints + 3 OIDC browser-flow endpoints; rest-
|
|
# deferred bucket goes from 7 → 0 with this commit. Browser-flow
|
|
# endpoints model the 302+Location-header redirect contract per
|
|
# OAS 3.1; consumers know they are NOT standard JSON responses.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
/api/v1/audit/export:
|
|
get:
|
|
tags: [Audit]
|
|
summary: Export audit events as newline-delimited JSON (NDJSON) for a date range
|
|
description: |
|
|
Permission `audit.export`. Streams every audit row inside the
|
|
requested `[from, to]` window as `application/x-ndjson`. Used
|
|
by compliance pipelines (Splunk Universal Forwarder, Elastic
|
|
Filebeat, Vector, etc.) that prefer line-by-line ingestion
|
|
over a single JSON document.
|
|
|
|
Range cap: 90 days. Requests with `to - from > 90d` return
|
|
400; paginate by narrower windows.
|
|
|
|
Per-record cap: `limit` query parameter (default 50000;
|
|
accepted range 1..100000). Values outside the range silently
|
|
clamp to default.
|
|
|
|
The export itself is recursively audited: every successful
|
|
export emits an `audit.export` event capturing actor, range,
|
|
category, and row count so the audit log records who pulled
|
|
which compliance evidence and when.
|
|
operationId: exportAudit
|
|
parameters:
|
|
- in: query
|
|
name: from
|
|
required: true
|
|
schema: { type: string, format: date-time }
|
|
description: RFC 3339 start of the export window (inclusive).
|
|
- in: query
|
|
name: to
|
|
required: true
|
|
schema: { type: string, format: date-time }
|
|
description: RFC 3339 end of the export window (exclusive). Must be strictly after `from`.
|
|
- in: query
|
|
name: category
|
|
required: false
|
|
schema:
|
|
type: string
|
|
enum: [cert_lifecycle, auth, config]
|
|
description: Optional category filter. Omit to return every event in the window.
|
|
- in: query
|
|
name: limit
|
|
required: false
|
|
schema: { type: integer, minimum: 1, maximum: 100000 }
|
|
description: Maximum rows to stream (default 50000; out-of-range values clamp to default).
|
|
responses:
|
|
"200":
|
|
description: NDJSON stream of AuditEvent rows
|
|
headers:
|
|
Content-Disposition:
|
|
description: Attachment hint suggesting filename `certctl-audit-<from>_to_<to>.ndjson`.
|
|
schema: { type: string }
|
|
content:
|
|
application/x-ndjson:
|
|
schema:
|
|
type: string
|
|
description: |
|
|
One JSON-encoded AuditEvent per line (see
|
|
`#/components/schemas/AuditEvent`). The body is a
|
|
byte stream, not a single JSON document; clients
|
|
parse line-by-line.
|
|
"400": { description: Missing/invalid date params, range exceeds 90 days, invalid category, etc. }
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"405": { description: Method not allowed (only GET is accepted) }
|
|
"500": { description: Internal error (export failed mid-query) }
|
|
|
|
/api/v1/auth/demo-residual/cleanup:
|
|
post:
|
|
tags: [Auth]
|
|
summary: Remove residual `actor-demo-anon` role grants after exiting demo mode
|
|
description: |
|
|
Permission `auth.role.assign` (admin-class). Removes the
|
|
leftover `actor_roles` rows for the synthetic `actor-demo-anon`
|
|
actor after the operator has flipped `CERTCTL_AUTH_TYPE` away
|
|
from `none` (demo mode). Idempotent: subsequent invocations
|
|
return `removed: 0`.
|
|
|
|
Refuses (503) when the server is currently in demo mode —
|
|
the `actor-demo-anon` grants ARE the active runtime state at
|
|
`auth_type=none`, so "cleaning them up" would lock the operator
|
|
out of the live admin surface. The GUI hides the action button
|
|
when `/api/v1/auth/info` reports `auth_type=none`; this guard
|
|
is defense-in-depth.
|
|
operationId: cleanupDemoResidualGrants
|
|
responses:
|
|
"200":
|
|
description: Cleanup complete (count returned)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DemoResidualCleanupResponse"
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"500": { description: Internal error (cleanup not configured or failed mid-delete) }
|
|
"503": { description: Refused — server is currently in demo mode (CERTCTL_AUTH_TYPE=none) }
|
|
|
|
/auth/logout:
|
|
post:
|
|
tags: [Auth]
|
|
summary: Revoke the caller's current session
|
|
description: |
|
|
Auth-exempt at the router (the session cookie is validated
|
|
inside the handler). Reads `certctl_session` cookie, validates
|
|
+ revokes the underlying session row, rotates the CSRF token
|
|
on the actor's other sessions (Audit 2026-05-11 Fix 13 / HIGH-2
|
|
fourth call site), clears the session + CSRF cookies, returns
|
|
204.
|
|
|
|
Idempotent: when no valid session cookie is presented the
|
|
handler clears any stale cookies and returns 204 without
|
|
side-effects.
|
|
operationId: logoutCurrentSession
|
|
security: []
|
|
responses:
|
|
"204": { description: Session revoked (or none to revoke — idempotent) }
|
|
"500": { description: Internal error during revoke }
|
|
|
|
/auth/breakglass/login:
|
|
post:
|
|
tags: [Auth]
|
|
summary: Local password login for emergency admin recovery
|
|
description: |
|
|
Auth-bypass — the whole point is to log in WITHOUT existing
|
|
certctl credentials. On success, sets the post-login session
|
|
cookie + CSRF cookie and returns 204. On any failure (wrong
|
|
password, locked account, no credential, unknown actor):
|
|
uniform 401 + identical timing (no scanner-friendly
|
|
distinction).
|
|
|
|
Returns 404 when `CERTCTL_BREAKGLASS_ENABLED=false` (surface
|
|
invisibility — Phase 7.5 spec).
|
|
|
|
Rate-limited per source IP (default 5 attempts/min/IP;
|
|
configurable via `CERTCTL_RATE_LIMIT_BACKEND` + the
|
|
constructor in cmd/server/main.go). Exceeded budget returns
|
|
429 with no body.
|
|
operationId: breakglassLogin
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BreakglassLoginRequest"
|
|
responses:
|
|
"204":
|
|
description: Authenticated — Set-Cookie carries the new session + CSRF tokens
|
|
headers:
|
|
Set-Cookie:
|
|
description: |
|
|
Two `Set-Cookie` headers are emitted on success: the
|
|
post-login session cookie (`certctl_session`, HttpOnly)
|
|
and the CSRF token cookie (`certctl_csrf`, NOT HttpOnly
|
|
so the GUI can read it to echo in `X-CSRF-Token`).
|
|
schema: { type: string }
|
|
"401": { description: Authentication failed (uniform — covers wrong password, missing credential, unknown actor; identical timing) }
|
|
"404": { description: Break-glass disabled (CERTCTL_BREAKGLASS_ENABLED=false) }
|
|
"429": { description: Rate limit exceeded (per source IP) }
|
|
"500": { description: Internal error during authenticate }
|
|
|
|
/auth/oidc/login:
|
|
get:
|
|
tags: [Auth]
|
|
summary: Browser-flow — start an OIDC login; 302 to IdP authorization URL
|
|
description: |
|
|
Auth-exempt — pre-auth by definition. Browser-flow endpoint:
|
|
the response is a 302 with `Location:` pointing at the
|
|
configured provider's authorization URL, NOT a JSON response.
|
|
Consumers MUST follow the redirect for the flow to complete.
|
|
|
|
On success: persists a pre-login row capturing the state +
|
|
nonce + PKCE-S256 verifier, sets the `certctl_oidc_pending`
|
|
cookie (HttpOnly, SameSite=Lax, 10-minute lifetime, `__Host-`
|
|
prefix), and 302-redirects to the IdP. The cookie is consumed
|
|
+ cleared by `/auth/oidc/callback`.
|
|
|
|
Audit 2026-05-10 MED-16 — the pre-login row captures the
|
|
client IP + User-Agent at this step so the callback handler
|
|
can reject a stolen cookie replayed from a different browser
|
|
or source.
|
|
operationId: oidcLoginInitiate
|
|
security: []
|
|
parameters:
|
|
- in: query
|
|
name: provider
|
|
required: true
|
|
schema: { type: string }
|
|
description: OIDC provider ID (one of the rows under `/api/v1/auth/oidc/providers`).
|
|
responses:
|
|
"302":
|
|
description: Redirect to the IdP's authorization URL
|
|
headers:
|
|
Location:
|
|
description: IdP authorization URL (provider-specific, includes state + nonce + PKCE challenge).
|
|
schema: { type: string, format: uri }
|
|
Set-Cookie:
|
|
description: The `certctl_oidc_pending` pre-login cookie.
|
|
schema: { type: string }
|
|
"400": { description: Missing `provider` query parameter }
|
|
"404": { description: Provider not found }
|
|
"500": { description: IdP discovery fetch failed, downgrade-defense tripped, or internal crypto failure }
|
|
|
|
/auth/oidc/callback:
|
|
get:
|
|
tags: [Auth]
|
|
summary: Browser-flow — consume IdP authorization response; 302 to post-login URL
|
|
description: |
|
|
Auth-exempt — pre-auth by definition (the cookie + state are
|
|
validated inside the handler). Browser-flow endpoint: the
|
|
success response is a 302 with `Location:` pointing at the
|
|
configured `postLoginURL` (default `/`), NOT a JSON response.
|
|
|
|
Reads the `certctl_oidc_pending` pre-login cookie, drives the
|
|
OIDC service's 11-step token validation (sig + iss + aud +
|
|
nonce + at_hash + iat + jti + sub + group-claim resolution +
|
|
role-mapping + user-upsert), mints a post-login session,
|
|
deletes the pre-login cookie, sets the post-login session +
|
|
CSRF cookies, and 302's to the dashboard.
|
|
|
|
Failure responses are ALSO 302 — to `/login?error=oidc_failed&reason=<category>`
|
|
— so the SPA can render an operator-friendly alert without
|
|
the browser seeing a raw 400. The audit row carries the
|
|
specific failure category server-side.
|
|
operationId: oidcLoginCallback
|
|
security: []
|
|
parameters:
|
|
- in: query
|
|
name: code
|
|
required: true
|
|
schema: { type: string }
|
|
description: OAuth2 authorization code returned by the IdP.
|
|
- in: query
|
|
name: state
|
|
required: true
|
|
schema: { type: string }
|
|
description: Opaque state value the certctl `/auth/oidc/login` step embedded into the IdP URL.
|
|
- in: query
|
|
name: iss
|
|
required: false
|
|
schema: { type: string }
|
|
description: RFC 9207 `iss` URL parameter. Preserved byte-strict for the service-layer compare against the matched provider's `IssuerURL`. The IdP emits this only when advertised in its discovery doc; the service-layer check is a no-op otherwise.
|
|
responses:
|
|
"302":
|
|
description: Redirect — either to `postLoginURL` on success OR to `/login?error=oidc_failed&reason=<category>` on validation failure
|
|
headers:
|
|
Location:
|
|
description: Either the configured `postLoginURL` (success) or `/login?error=oidc_failed&reason=<category>` (failure).
|
|
schema: { type: string, format: uri-reference }
|
|
Set-Cookie:
|
|
description: On success — two `Set-Cookie` headers carrying the post-login session + CSRF tokens. On failure — a single `Set-Cookie` clearing the pre-login cookie.
|
|
schema: { type: string }
|
|
"400": { description: Missing `code`/`state` query parameter, or missing pre-login cookie }
|
|
|
|
/auth/oidc/back-channel-logout:
|
|
post:
|
|
tags: [Auth]
|
|
summary: IdP-initiated session revocation via OIDC Back-Channel Logout 1.0
|
|
description: |
|
|
Auth via the IdP-signed `logout_token` JWT in the form body —
|
|
NOT certctl-issued credentials, so `security: []`. Spec
|
|
reference: OpenID Connect Back-Channel Logout 1.0 §2.6.
|
|
|
|
Validates the logout token against the matched provider's
|
|
JWKS (signature + alg-allowlist + iat-skew window + jti
|
|
consumed-set + required claims: iss, aud, iat, jti, events;
|
|
exactly one of sub or sid; nonce MUST be absent), revokes
|
|
every matching session, returns 200 with `Cache-Control:
|
|
no-store`.
|
|
|
|
Any validation failure returns 400 — uniform wire shape per
|
|
spec §2.6. The audit row carries the specific reason.
|
|
Replayed jti (RFC 9700 §2.7) returns 200 with audit
|
|
outcome=jti_replayed (idempotent re-receive of a logout
|
|
is harmless).
|
|
operationId: oidcBackChannelLogout
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/x-www-form-urlencoded:
|
|
schema:
|
|
type: object
|
|
required: [logout_token]
|
|
properties:
|
|
logout_token:
|
|
type: string
|
|
description: IdP-signed logout_token JWT per OpenID Connect Back-Channel Logout 1.0 §2.4.
|
|
responses:
|
|
"200":
|
|
description: Logout token accepted; matching sessions revoked
|
|
headers:
|
|
Cache-Control:
|
|
description: Always `no-store` per spec.
|
|
schema: { type: string, enum: [no-store] }
|
|
"400": { description: logout_token validation failed (signature, iss, aud, iat-window, jti consumed-set, required claims, etc.) — uniform per spec §2.6 }
|
|
"503": { description: Transient repository error on jti consumed-set write — IdP should retry per its own semantics }
|
|
|
|
/api/v1/auth/runtime-config:
|
|
get:
|
|
tags: [Auth]
|
|
summary: Read the deployed auth-related runtime configuration
|
|
description: |
|
|
Permission `auth.role.assign` (admin-class — gated tighter
|
|
than `auth.user.read` so non-admins can't enumerate the
|
|
deployment's auth knobs). Audit 2026-05-10 MED-12 — backs the
|
|
GUI AuthSettings page so operators can verify the deployed
|
|
configuration matches their intent from the browser without
|
|
SSH access to the host.
|
|
|
|
Read-only — no mutation surface. Config changes require a
|
|
restart + env-var edit by design.
|
|
operationId: getAuthRuntimeConfig
|
|
responses:
|
|
"200":
|
|
description: Flat-map view of the relevant CERTCTL_* env vars
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [runtime_config]
|
|
properties:
|
|
runtime_config:
|
|
type: object
|
|
additionalProperties: { type: string }
|
|
description: |
|
|
Map of CERTCTL_* env var name → resolved value.
|
|
The exact key set depends on the deployment's
|
|
configured auth surface (OIDC, break-glass, etc.).
|
|
"401": { description: Unauthorized }
|
|
"403": { description: Forbidden }
|
|
"500": { description: Internal error }
|
|
|
|
/api/v1/version:
|
|
get:
|
|
tags: [Health]
|
|
summary: Build identity (version, commit, Go runtime)
|
|
description: |
|
|
Returns the running server's build identity. Served without
|
|
auth so rollout systems and blackbox probes can read it without
|
|
Bearer credentials. U-3 ride-along (cat-u-no_version_endpoint).
|
|
Excluded from audit logging because rollout polling would
|
|
otherwise dominate the audit trail.
|
|
|
|
The Version field follows a fallback ladder: ldflags-supplied
|
|
value > VCS commit SHA > "dev". Commit / Modified / BuildTime
|
|
come from runtime/debug.BuildInfo (Go 1.18+ stamps these on
|
|
every module-tracked build). GoVersion is runtime.Version().
|
|
security: []
|
|
operationId: getVersion
|
|
responses:
|
|
"200":
|
|
description: Build identity
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [version, commit, modified, build_time, go_version]
|
|
properties:
|
|
version:
|
|
type: string
|
|
description: Release tag (ldflags-supplied) or VCS SHA fallback or "dev"
|
|
example: v2.0.51
|
|
commit:
|
|
type: string
|
|
description: Git SHA from runtime/debug.BuildInfo (vcs.revision); empty when not VCS-tracked
|
|
modified:
|
|
type: boolean
|
|
description: True when build had uncommitted changes (vcs.modified)
|
|
build_time:
|
|
type: string
|
|
description: RFC 3339 build timestamp (vcs.time); empty when not VCS-tracked
|
|
go_version:
|
|
type: string
|
|
description: Go toolchain version that compiled the binary (runtime.Version())
|
|
example: go1.25.10
|
|
|
|
# ─── Certificates ────────────────────────────────────────────────────
|
|
/api/v1/certificates:
|
|
get:
|
|
tags: [Certificates]
|
|
summary: List certificates
|
|
operationId: listCertificates
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
- name: status
|
|
in: query
|
|
schema:
|
|
$ref: "#/components/schemas/CertificateStatus"
|
|
- name: environment
|
|
in: query
|
|
schema:
|
|
type: string
|
|
- name: owner_id
|
|
in: query
|
|
schema:
|
|
type: string
|
|
- name: team_id
|
|
in: query
|
|
schema:
|
|
type: string
|
|
- name: issuer_id
|
|
in: query
|
|
schema:
|
|
type: string
|
|
responses:
|
|
"200":
|
|
description: Paginated list of certificates
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/ManagedCertificate"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Certificates]
|
|
summary: Create certificate
|
|
operationId: createCertificate
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ManagedCertificate"
|
|
responses:
|
|
"201":
|
|
description: Certificate created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ManagedCertificate"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/certificates/{id}:
|
|
get:
|
|
tags: [Certificates]
|
|
summary: Get certificate
|
|
operationId: getCertificate
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Certificate details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ManagedCertificate"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [Certificates]
|
|
summary: Update certificate
|
|
operationId: updateCertificate
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ManagedCertificate"
|
|
responses:
|
|
"200":
|
|
description: Certificate updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ManagedCertificate"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Certificates]
|
|
summary: Archive certificate
|
|
operationId: archiveCertificate
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Certificate archived
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/certificates/{id}/versions:
|
|
get:
|
|
tags: [Certificates]
|
|
summary: List certificate versions
|
|
operationId: listCertificateVersions
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of certificate versions
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/CertificateVersion"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/certificates/{id}/renew:
|
|
post:
|
|
tags: [Certificates]
|
|
summary: Trigger certificate renewal
|
|
operationId: triggerRenewal
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"202":
|
|
description: Renewal triggered
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"409":
|
|
$ref: "#/components/responses/Conflict"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/certificates/{id}/deploy:
|
|
post:
|
|
tags: [Certificates]
|
|
summary: Trigger certificate deployment
|
|
operationId: triggerDeployment
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
target_id:
|
|
type: string
|
|
description: Optional specific target ID
|
|
responses:
|
|
"202":
|
|
description: Deployment triggered
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/certificates/{id}/revoke:
|
|
post:
|
|
tags: [Certificates]
|
|
summary: Revoke certificate
|
|
description: |
|
|
Revokes a certificate with an optional RFC 5280 reason code. Records revocation in
|
|
cert inventory, audit log, and certificate_revocations table. Best-effort issuer notification.
|
|
operationId: revokeCertificate
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
reason:
|
|
$ref: "#/components/schemas/RevocationReason"
|
|
responses:
|
|
"200":
|
|
description: Certificate revoked
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Bulk Revocation ─────────────────────────────────────────────────
|
|
/api/v1/certificates/bulk-revoke:
|
|
post:
|
|
tags: [Certificates]
|
|
summary: Bulk revoke certificates
|
|
description: |
|
|
Revokes all certificates matching the given filter criteria. At least one criterion
|
|
is required (safety guard against accidental mass revocation). Reuses the single-cert
|
|
revocation flow per certificate with partial-failure tolerance.
|
|
operationId: bulkRevokeCertificates
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BulkRevokeRequest"
|
|
responses:
|
|
"200":
|
|
description: Bulk revocation result
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BulkRevokeResult"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/est/certificates/bulk-revoke:
|
|
post:
|
|
tags: [EST, Certificates]
|
|
summary: Bulk revoke EST-issued certificates (admin)
|
|
description: |
|
|
EST-source-scoped bulk revocation. Identical wire shape to
|
|
/api/v1/certificates/bulk-revoke; the handler pins
|
|
`Source=EST` so the operation only affects certs the EST
|
|
service stamped at issuance time. SCEP-issued / API-issued /
|
|
Agent-provisioned certs are never touched by this endpoint.
|
|
|
|
At least one narrower criterion (profile_id, owner_id,
|
|
agent_id, issuer_id, team_id, or certificate_ids) is
|
|
required — Source-only requests are rejected as too broad
|
|
to prevent accidental fleet-wide revocation. Admin-gated
|
|
(M-008 / M-003 pattern). Audit action emitted: `est_bulk_revoke`.
|
|
|
|
EST RFC 7030 hardening master bundle Phase 11.2.
|
|
operationId: bulkRevokeESTCertificates
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BulkRevokeRequest"
|
|
responses:
|
|
"200":
|
|
description: Bulk revocation result (same shape as the generic endpoint)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BulkRevokeResult"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"403":
|
|
description: Admin access required
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/certificates/bulk-renew:
|
|
post:
|
|
tags: [Certificates]
|
|
summary: Bulk renew certificates by criteria or explicit IDs
|
|
description: |
|
|
Enqueues a renewal job for every matching managed certificate. Mirrors POST
|
|
/api/v1/certificates/bulk-revoke shape exactly so operators who already know
|
|
that contract have zero new surface to learn. L-1 closure
|
|
(cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped per-cert HTTP calls;
|
|
post-L-1 it's a single POST. Status filter: certs in
|
|
Archived/Revoked/Expired/RenewalInProgress are silent-skipped (TotalSkipped++)
|
|
rather than returned as errors. Asynchronous: the action ENQUEUES jobs the
|
|
scheduler picks up; per-cert {certificate_id, job_id} pairs are returned in
|
|
enqueued_jobs. NOT admin-gated — bulk renewal is non-destructive.
|
|
operationId: bulkRenewCertificates
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BulkRenewRequest"
|
|
responses:
|
|
"200":
|
|
description: Bulk renewal result
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BulkRenewResult"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/certificates/bulk-reassign:
|
|
post:
|
|
tags: [Certificates]
|
|
summary: Bulk reassign owner (and optionally team) for a set of certificates
|
|
description: |
|
|
Updates owner_id (required) and team_id (optional) on every certificate in
|
|
certificate_ids. Skips certs already owned by the target (silent no-op,
|
|
TotalSkipped++). L-2 closure (cat-l-8a1fb258a38a). Narrower than bulk-renew:
|
|
explicit IDs only, no criteria-mode. The OwnerID is validated upfront — a
|
|
non-existent owner returns 400 before any cert is touched. Verb chosen as
|
|
POST (not PATCH) for codebase consistency with bulk-revoke and bulk-renew.
|
|
operationId: bulkReassignCertificates
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BulkReassignRequest"
|
|
responses:
|
|
"200":
|
|
description: Bulk reassignment result
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BulkReassignResult"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Certificate Export ──────────────────────────────────────────────
|
|
/api/v1/certificates/{id}/export/pem:
|
|
get:
|
|
tags: [Certificates]
|
|
summary: Export certificate as PEM
|
|
description: |
|
|
Returns the certificate and its chain in PEM format. By default returns JSON
|
|
with cert_pem, chain_pem, and full_pem fields. Add ?download=true to get the
|
|
full PEM chain as a file download with Content-Disposition headers.
|
|
operationId: exportCertificatePEM
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
- name: download
|
|
in: query
|
|
schema:
|
|
type: string
|
|
enum: ["true"]
|
|
description: Set to "true" to get a file download instead of JSON.
|
|
responses:
|
|
"200":
|
|
description: PEM export
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
cert_pem:
|
|
type: string
|
|
description: Leaf certificate PEM
|
|
chain_pem:
|
|
type: string
|
|
description: Intermediate/root chain PEM
|
|
full_pem:
|
|
type: string
|
|
description: Full PEM chain (cert + intermediates)
|
|
application/x-pem-file:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
description: Full PEM file (when download=true)
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/certificates/{id}/export/pkcs12:
|
|
post:
|
|
tags: [Certificates]
|
|
summary: Export certificate as PKCS#12
|
|
description: |
|
|
Returns a PKCS#12 (.p12) bundle containing the certificate and chain.
|
|
Private keys are NOT included — they live on agents and never touch the control plane.
|
|
The bundle is encrypted with the provided password (or empty password if omitted).
|
|
operationId: exportCertificatePKCS12
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
password:
|
|
type: string
|
|
description: Password to encrypt the PKCS#12 bundle (can be empty)
|
|
responses:
|
|
"200":
|
|
description: PKCS#12 binary
|
|
content:
|
|
application/x-pkcs12:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── PKI (CRL & OCSP, RFC 5280 / 6960 / 8615) ──────────────────────
|
|
#
|
|
# Relying parties (browsers, OpenSSL clients, OCSP stapling sidecars,
|
|
# mTLS clients) cannot present a certctl Bearer token, so these two
|
|
# endpoints are unauthenticated and live under the RFC 8615
|
|
# `.well-known` namespace. They were previously mounted at
|
|
# /api/v1/crl/{issuer_id} and /api/v1/ocsp/{issuer_id}/{serial}; those
|
|
# paths were removed in M-006.
|
|
#
|
|
# The non-standard JSON CRL endpoint (GET /api/v1/crl) was also
|
|
# removed — RFC 5280 defines only the DER wire format.
|
|
/.well-known/pki/crl/{issuer_id}:
|
|
get:
|
|
tags: [CRL & OCSP]
|
|
summary: Get DER-encoded X.509 CRL (RFC 5280)
|
|
description: |
|
|
Returns a DER-encoded CRL signed by the issuing CA (RFC 5280 §5),
|
|
served unauthenticated per RFC 8615 `.well-known` semantics so
|
|
relying parties can retrieve it without a certctl API key.
|
|
Validity is 24 hours.
|
|
operationId: getDERCRL
|
|
security: []
|
|
parameters:
|
|
- name: issuer_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
"200":
|
|
description: DER-encoded CRL
|
|
content:
|
|
application/pkix-crl:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
"501":
|
|
description: Issuer does not support CRL generation
|
|
|
|
/.well-known/pki/ocsp/{issuer_id}/{serial}:
|
|
get:
|
|
tags: [CRL & OCSP]
|
|
summary: OCSP responder (RFC 6960)
|
|
description: |
|
|
Returns a signed OCSP response (good/revoked/unknown) for the
|
|
given serial number per RFC 6960 §2.1, served unauthenticated
|
|
per RFC 8615 so relying parties and OCSP stapling sidecars can
|
|
query revocation status without a certctl API key.
|
|
operationId: handleOCSP
|
|
security: []
|
|
parameters:
|
|
- name: issuer_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
- name: serial
|
|
in: path
|
|
required: true
|
|
description: Hex-encoded certificate serial number
|
|
schema:
|
|
type: string
|
|
responses:
|
|
"200":
|
|
description: OCSP response
|
|
content:
|
|
application/ocsp-response:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
"501":
|
|
description: Issuer does not support OCSP
|
|
|
|
/api/v1/admin/crl/cache:
|
|
get:
|
|
tags: [CRL & OCSP]
|
|
summary: Inspect CRL pre-generation cache (admin)
|
|
description: |
|
|
Returns the per-issuer CRL cache state populated by the
|
|
scheduler's crlGenerationLoop. One row per registered issuer
|
|
with `cache_present` indicating whether a CRL has ever been
|
|
generated, plus `is_stale` derived from `next_update` vs.
|
|
wall clock, plus the most recent generation events for
|
|
ops grep.
|
|
|
|
Admin-gated (M-003 pattern). Bundle CRL/OCSP-Responder Phase 5.
|
|
operationId: listCRLCache
|
|
responses:
|
|
"200":
|
|
description: Cache state per issuer
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
cache_rows:
|
|
type: array
|
|
items:
|
|
type: object
|
|
row_count:
|
|
type: integer
|
|
generated_at:
|
|
type: string
|
|
format: date-time
|
|
"403":
|
|
description: Admin access required
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/network-scan/scep-probe:
|
|
post:
|
|
tags: [SCEP]
|
|
summary: Probe an SCEP server for capability + posture
|
|
description: |
|
|
Synchronous probe against an SCEP server URL. Issues
|
|
`GET ?operation=GetCACaps` and `GET ?operation=GetCACert`
|
|
and returns the structured `SCEPProbeResult` (reachable,
|
|
advertised caps, RFC 8894 / AES / POST / Renewal / SHA-256 /
|
|
SHA-512 support flags, CA cert subject + issuer + NotBefore +
|
|
NotAfter + days-to-expiry + algorithm + chain length).
|
|
|
|
Capability-only — does NOT POST a CSR (would consume slot
|
|
allocations on the target server + create audit noise). Used
|
|
for pre-migration assessment + compliance posture audits.
|
|
|
|
SSRF-defended: the URL is validated up-front (reserved IPs
|
|
rejected) AND the underlying HTTP client uses the
|
|
SafeHTTPDialContext that re-resolves the host at dial time
|
|
(defends against DNS rebinding).
|
|
|
|
Result is persisted to the `scep_probe_results` table via
|
|
migration 000021 so the GUI can show recent probe history.
|
|
SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
|
operationId: probeSCEP
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [url]
|
|
properties:
|
|
url:
|
|
type: string
|
|
format: uri
|
|
description: Base SCEP server URL (no `?operation=...` suffix needed; the probe appends its own operations).
|
|
responses:
|
|
"200":
|
|
description: Probe completed (the result body's `error` field carries any sub-step failure)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
target_url:
|
|
type: string
|
|
reachable:
|
|
type: boolean
|
|
advertised_caps:
|
|
type: array
|
|
items: { type: string }
|
|
supports_rfc8894: { type: boolean }
|
|
supports_aes: { type: boolean }
|
|
supports_post_operation: { type: boolean }
|
|
supports_renewal: { type: boolean }
|
|
supports_sha256: { type: boolean }
|
|
supports_sha512: { type: boolean }
|
|
ca_cert_subject: { type: string }
|
|
ca_cert_issuer: { type: string }
|
|
ca_cert_not_before: { type: string, format: date-time }
|
|
ca_cert_not_after: { type: string, format: date-time }
|
|
ca_cert_expired: { type: boolean }
|
|
ca_cert_days_to_expiry: { type: integer }
|
|
ca_cert_algorithm: { type: string }
|
|
ca_cert_chain_length: { type: integer }
|
|
probed_at: { type: string, format: date-time }
|
|
probe_duration_ms: { type: integer }
|
|
error: { type: string }
|
|
"400":
|
|
description: Missing or malformed `url` field
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/network-scan/scep-probes:
|
|
get:
|
|
tags: [SCEP]
|
|
summary: List recent SCEP probe results
|
|
description: |
|
|
Returns the most recent 50 SCEP probe results across any
|
|
target URL, ordered by `probed_at` descending. Backs the
|
|
GUI's "Recent SCEP probes" history table on the Network
|
|
Scan page. SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
|
operationId: listSCEPProbes
|
|
responses:
|
|
"200":
|
|
description: Recent probe results
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
probes:
|
|
type: array
|
|
items:
|
|
type: object
|
|
probe_count:
|
|
type: integer
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/admin/scep/profiles:
|
|
get:
|
|
tags: [SCEP]
|
|
summary: Per-profile SCEP administration overview (admin)
|
|
description: |
|
|
Returns one snapshot per configured SCEP profile in the
|
|
SCEPProfileStatsSnapshot shape: always-present per-profile
|
|
fields (path_id, issuer_id, challenge_password_set, RA cert
|
|
subject + NotBefore/NotAfter + days-to-expiry, mTLS
|
|
sibling-route status, mTLS trust bundle path) plus an
|
|
optional `intune` sub-block when the profile has
|
|
INTUNE_ENABLED=true.
|
|
|
|
Profiles where Intune is disabled appear with the `intune`
|
|
field omitted (rather than null) so the GUI's per-profile
|
|
card can render the lean shape without an Intune deep-dive
|
|
button. Profiles where Intune is enabled also appear in the
|
|
sibling /api/v1/admin/scep/intune/stats endpoint with the
|
|
flat Phase 9.2 shape preserved for backward compat.
|
|
|
|
Admin-gated (M-008 pattern). Non-admin Bearer callers get
|
|
HTTP 403 — the snapshot reveals the operator's profile set,
|
|
RA cert expiries, and mTLS bundle paths (sensitive
|
|
operational metadata). SCEP RFC 8894 + Intune master bundle
|
|
Phase 9 follow-up.
|
|
operationId: listSCEPProfiles
|
|
responses:
|
|
"200":
|
|
description: Per-profile SCEP administration snapshot
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
profiles:
|
|
type: array
|
|
items:
|
|
type: object
|
|
profile_count:
|
|
type: integer
|
|
generated_at:
|
|
type: string
|
|
format: date-time
|
|
"403":
|
|
description: Admin access required
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/admin/scep/intune/stats:
|
|
get:
|
|
tags: [SCEP]
|
|
summary: Per-profile Microsoft Intune dispatcher observability (admin)
|
|
description: |
|
|
Returns one snapshot per configured SCEP profile (Intune-enabled
|
|
or not). Profiles where Intune is disabled appear with
|
|
`enabled=false`; profiles where Intune is enabled additionally
|
|
carry the trust anchor pool's per-cert expiry, the audience
|
|
binding, the per-status enrollment counters
|
|
(success / signature_invalid / claim_mismatch / expired /
|
|
wrong_audience / replay / rate_limited / malformed /
|
|
compliance_failed / not_yet_valid / unknown_version), the
|
|
in-memory replay-cache size, and the per-device-rate-limit
|
|
opt-out flag.
|
|
|
|
Admin-gated (M-008 pattern) — non-admin Bearer callers get 403
|
|
because the trust-anchor expiries and per-status counters are
|
|
sensitive operational metadata. SCEP RFC 8894 + Intune master
|
|
bundle Phase 9.2.
|
|
operationId: listSCEPIntuneStats
|
|
responses:
|
|
"200":
|
|
description: Per-profile Intune stats snapshot
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
profiles:
|
|
type: array
|
|
items:
|
|
type: object
|
|
profile_count:
|
|
type: integer
|
|
generated_at:
|
|
type: string
|
|
format: date-time
|
|
"403":
|
|
description: Admin access required
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/admin/scep/intune/reload-trust:
|
|
post:
|
|
tags: [SCEP]
|
|
summary: Reload a SCEP profile's Intune trust anchor (admin)
|
|
description: |
|
|
Triggers the same Reload that the SIGHUP watcher would run for
|
|
the named profile. The body MUST be `{"path_id": "<pathID>"}`;
|
|
an empty body targets the legacy `/scep` root profile (PathID="").
|
|
|
|
Returns 200 + `{"reloaded": true, ...}` on success; 404 when the
|
|
path_id doesn't match any configured SCEP profile; 409 when the
|
|
profile exists but Intune is disabled on it (no trust anchor to
|
|
reload); 500 when the underlying file fails to parse — in which
|
|
case the holder retains the OLD pool so enrollment keeps working
|
|
off the previous trust anchor while the operator fixes the file.
|
|
|
|
Admin-gated (M-008 pattern). SCEP RFC 8894 + Intune master
|
|
bundle Phase 9.2.
|
|
operationId: reloadSCEPIntuneTrust
|
|
requestBody:
|
|
required: false
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
path_id:
|
|
type: string
|
|
description: SCEP profile PathID (empty string = legacy /scep root)
|
|
responses:
|
|
"200":
|
|
description: Trust anchor reloaded
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
reloaded:
|
|
type: boolean
|
|
path_id:
|
|
type: string
|
|
reloaded_at:
|
|
type: string
|
|
format: date-time
|
|
"400":
|
|
description: Invalid JSON body
|
|
"403":
|
|
description: Admin access required
|
|
"404":
|
|
description: SCEP profile not found for the given path_id
|
|
"409":
|
|
description: SCEP profile exists but Intune is disabled
|
|
"500":
|
|
description: Trust anchor reload failed (the OLD pool is retained)
|
|
|
|
/api/v1/admin/est/profiles:
|
|
get:
|
|
tags: [EST]
|
|
summary: Per-profile EST administration overview (admin)
|
|
description: |
|
|
Returns one snapshot per configured EST profile with always-present
|
|
per-profile fields (path_id, issuer_id, profile_id, mtls_enabled,
|
|
basic_auth_configured, server_keygen_enabled, counters) plus an
|
|
optional trust-anchor sub-block when the profile has MTLS_ENABLED=true.
|
|
|
|
Counter labels: success_simpleenroll, success_simplereenroll,
|
|
success_serverkeygen, auth_failed_basic, auth_failed_mtls,
|
|
auth_failed_channel_binding, csr_invalid, csr_policy_violation,
|
|
csr_signature_mismatch, rate_limited, issuer_error, internal_error.
|
|
|
|
Admin-gated (M-008 pattern). Non-admin Bearer callers get HTTP 403 —
|
|
the snapshot reveals operator profile set, mTLS trust-anchor expiries,
|
|
and auth-mode posture (sensitive operational metadata). EST RFC 7030
|
|
hardening master bundle Phase 7.2.
|
|
operationId: listESTProfiles
|
|
responses:
|
|
"200":
|
|
description: Per-profile EST administration snapshot
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
profiles:
|
|
type: array
|
|
items:
|
|
type: object
|
|
profile_count:
|
|
type: integer
|
|
generated_at:
|
|
type: string
|
|
format: date-time
|
|
"403":
|
|
description: Admin access required
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/admin/est/reload-trust:
|
|
post:
|
|
tags: [EST]
|
|
summary: Reload an EST profile's mTLS trust anchor (admin)
|
|
description: |
|
|
Triggers the same Reload that the SIGHUP watcher would run for
|
|
the named EST profile. The body MUST be `{"path_id": "<pathID>"}`;
|
|
an empty body targets the legacy `/.well-known/est` root profile
|
|
(PathID="").
|
|
|
|
Returns 200 + `{"reloaded": true, ...}` on success; 404 when the
|
|
path_id doesn't match any configured EST profile; 409 when the
|
|
profile exists but mTLS is disabled on it (no trust anchor to
|
|
reload); 500 when the underlying file fails to parse — in which
|
|
case the holder retains the OLD pool so enrollment keeps working
|
|
off the previous trust anchor while the operator fixes the file.
|
|
|
|
Admin-gated (M-008 pattern). EST RFC 7030 hardening master
|
|
bundle Phase 7.2.
|
|
operationId: reloadESTTrust
|
|
requestBody:
|
|
required: false
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
path_id:
|
|
type: string
|
|
description: EST profile PathID (empty string = legacy /.well-known/est root)
|
|
responses:
|
|
"200":
|
|
description: Trust anchor reloaded
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
reloaded:
|
|
type: boolean
|
|
path_id:
|
|
type: string
|
|
reloaded_at:
|
|
type: string
|
|
format: date-time
|
|
"400":
|
|
description: Invalid JSON body
|
|
"403":
|
|
description: Admin access required
|
|
"404":
|
|
description: EST profile not found for the given path_id
|
|
"409":
|
|
description: EST profile exists but mTLS is disabled
|
|
"500":
|
|
description: Trust anchor reload failed (the OLD pool is retained)
|
|
|
|
/.well-known/pki/ocsp/{issuer_id}:
|
|
post:
|
|
tags: [CRL & OCSP]
|
|
summary: OCSP responder (RFC 6960 §A.1.1, POST form)
|
|
description: |
|
|
Standard RFC 6960 §A.1.1 POST form of the OCSP responder. The
|
|
request body is the binary DER-encoded OCSPRequest with
|
|
Content-Type `application/ocsp-request`; the serial number is
|
|
carried inside that body, not in the URL path. Most production
|
|
OCSP clients (Firefox, OpenSSL `s_client -status`, cert-manager,
|
|
Microsoft Intune device validators) use POST exclusively.
|
|
|
|
The pre-existing GET form
|
|
(`/.well-known/pki/ocsp/{issuer_id}/{serial}`) is preserved for
|
|
ad-hoc curl inspection and human-readable URL paths; behaviour
|
|
and response are otherwise identical.
|
|
|
|
Auth-exempt under `/.well-known/pki/*` per RFC 8615 so relying
|
|
parties can poll without a certctl API key. CRL/OCSP-Responder
|
|
bundle Phase 4.
|
|
operationId: handleOCSPPost
|
|
security: []
|
|
parameters:
|
|
- name: issuer_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/ocsp-request:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
description: DER-encoded OCSPRequest per RFC 6960 §4.1
|
|
responses:
|
|
"200":
|
|
description: OCSP response
|
|
content:
|
|
application/ocsp-response:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"415":
|
|
description: Content-Type is not application/ocsp-request
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
"501":
|
|
description: Issuer does not support OCSP
|
|
|
|
# ─── Issuers ─────────────────────────────────────────────────────────
|
|
/api/v1/issuers:
|
|
get:
|
|
tags: [Issuers]
|
|
summary: List issuers
|
|
operationId: listIssuers
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of issuers
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/Issuer"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Issuers]
|
|
summary: Create issuer
|
|
operationId: createIssuer
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Issuer"
|
|
responses:
|
|
"201":
|
|
description: Issuer created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Issuer"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/issuers/{id}:
|
|
get:
|
|
tags: [Issuers]
|
|
summary: Get issuer
|
|
operationId: getIssuer
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Issuer details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Issuer"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [Issuers]
|
|
summary: Update issuer
|
|
operationId: updateIssuer
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Issuer"
|
|
responses:
|
|
"200":
|
|
description: Issuer updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Issuer"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Issuers]
|
|
summary: Delete issuer
|
|
operationId: deleteIssuer
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Issuer deleted
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/issuers/{id}/test:
|
|
post:
|
|
tags: [Issuers]
|
|
summary: Test issuer connection
|
|
operationId: testIssuerConnection
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Connection successful
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Targets ─────────────────────────────────────────────────────────
|
|
/api/v1/targets:
|
|
get:
|
|
tags: [Targets]
|
|
summary: List targets
|
|
operationId: listTargets
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of targets
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/DeploymentTarget"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Targets]
|
|
summary: Create target
|
|
operationId: createTarget
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DeploymentTarget"
|
|
responses:
|
|
"201":
|
|
description: Target created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DeploymentTarget"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/targets/{id}:
|
|
get:
|
|
tags: [Targets]
|
|
summary: Get target
|
|
operationId: getTarget
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Target details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DeploymentTarget"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [Targets]
|
|
summary: Update target
|
|
operationId: updateTarget
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DeploymentTarget"
|
|
responses:
|
|
"200":
|
|
description: Target updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DeploymentTarget"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Targets]
|
|
summary: Delete target
|
|
operationId: deleteTarget
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Target deleted
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/targets/{id}/test:
|
|
post:
|
|
tags: [Targets]
|
|
summary: Test target connection
|
|
description: |
|
|
Checks target connectivity by verifying the assigned agent's heartbeat status
|
|
(agent reported within the last 5 minutes). Always returns HTTP 200 — the
|
|
connectivity result is reflected in the response body's `status` field
|
|
(`success` when the agent is reachable, `failed` otherwise).
|
|
operationId: testTargetConnection
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Connection test result (success or failed in body)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusMessageResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
|
|
# ─── Agents ──────────────────────────────────────────────────────────
|
|
/api/v1/agents:
|
|
get:
|
|
tags: [Agents]
|
|
summary: List agents
|
|
operationId: listAgents
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of agents
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/Agent"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Agents]
|
|
summary: Register agent
|
|
operationId: registerAgent
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Agent"
|
|
responses:
|
|
"201":
|
|
description: Agent registered
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Agent"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"409":
|
|
$ref: "#/components/responses/Conflict"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/agents/retired:
|
|
get:
|
|
tags: [Agents]
|
|
summary: List retired agents
|
|
description: |
|
|
I-004: opt-in listing of soft-retired agents. The default
|
|
`GET /api/v1/agents` endpoint filters retired rows out; this is the
|
|
dedicated surface for reading them back (e.g., the operator UI's
|
|
"Retired" tab, audit and forensics workflows). Pagination defaults
|
|
match the default agent listing (page=1, per_page=50, max 500). Go
|
|
1.22's enhanced ServeMux routes `/agents/retired` to this handler
|
|
via the literal-beats-pattern-var precedence rule, so the sibling
|
|
`/agents/{id}` route does not shadow it.
|
|
operationId: listRetiredAgents
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of retired agents
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/Agent"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/agents/{id}:
|
|
get:
|
|
tags: [Agents]
|
|
summary: Get agent
|
|
operationId: getAgent
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Agent details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Agent"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Agents]
|
|
summary: Soft-retire agent
|
|
description: |
|
|
I-004: soft-retirement. The agent row is preserved (so its audit
|
|
trail and historical job links remain intact) and `retired_at` is
|
|
stamped. A retired agent receives `410 Gone` on subsequent
|
|
heartbeats so it can shut down cleanly.
|
|
|
|
Behavior matrix:
|
|
|
|
| Scenario | Query | Status | Body |
|
|
| --- | --- | --- | --- |
|
|
| Clean retire (no active dependencies) | none | `200` | `RetireAgentResponse` with `cascade=false`, zero counts |
|
|
| Blocked by active targets/certs/jobs | none | `409` | `BlockedByDependenciesResponse` with per-bucket counts |
|
|
| Force-cascade retire | `force=true&reason=...` | `200` | `RetireAgentResponse` with `cascade=true`, pre-cascade counts |
|
|
| Idempotent re-retire | either | `204` | (empty — downstream consumers break on stray bodies) |
|
|
| `force=true` without reason | `force=true` | `400` | ErrorResponse (ErrForceReasonRequired) |
|
|
| Reserved sentinel agent | any | `403` | ErrorResponse (ErrAgentIsSentinel) |
|
|
| Unknown agent id | any | `404` | ErrorResponse |
|
|
|
|
Sentinel agents are the four reserved identities backing non-agent
|
|
discovery subsystems (`server-scanner`, `cloud-aws-sm`,
|
|
`cloud-azure-kv`, `cloud-gcp-sm`). Retiring them would orphan the
|
|
scanner or a cloud secret-manager source, so the handler refuses
|
|
unconditionally — even with `force=true`.
|
|
operationId: retireAgent
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
- name: force
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: boolean
|
|
default: false
|
|
description: |
|
|
Cascade-retire active downstream targets, certificates, and
|
|
jobs. When `true`, a non-empty `reason` is required. A
|
|
malformed value (anything strconv.ParseBool rejects) is
|
|
silently treated as `false` so a typoed query can never
|
|
accidentally enable the cascade.
|
|
- name: reason
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
description: |
|
|
Human-readable reason recorded on the retired row and in the
|
|
immutable audit trail. Required (non-empty after trimming)
|
|
when `force=true`.
|
|
responses:
|
|
"200":
|
|
description: |
|
|
Agent retired (clean retire or successful force-cascade). Body
|
|
is `RetireAgentResponse`.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/RetireAgentResponse"
|
|
"204":
|
|
description: |
|
|
Idempotent retire — the agent was already retired. Response
|
|
body is empty (the 200-path shape does not apply, and
|
|
downstream clients that tee responses into dashboards would
|
|
break on spurious bodies).
|
|
"400":
|
|
description: |
|
|
`force=true` was sent without a non-empty `reason`
|
|
(ErrForceReasonRequired).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorResponse"
|
|
"403":
|
|
description: |
|
|
Agent is a reserved sentinel and cannot be retired even with
|
|
`?force=true` (ErrAgentIsSentinel).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorResponse"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"409":
|
|
description: |
|
|
Blocked by active downstream dependencies. Body carries
|
|
per-bucket counts so the operator UI can show the user which
|
|
dependency is holding up the retire. Re-run with
|
|
`?force=true&reason=...` to cascade.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/BlockedByDependenciesResponse"
|
|
"405":
|
|
description: Method not allowed (only DELETE, GET are routed to this path)
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/agents/{id}/heartbeat:
|
|
post:
|
|
tags: [Agents]
|
|
summary: Agent heartbeat
|
|
description: |
|
|
Reports agent liveness and metadata (OS, architecture, IP, version).
|
|
|
|
I-004: a retired agent still polling the heartbeat endpoint receives
|
|
`410 Gone` so `cmd/agent` detects the terminal signal and shuts down
|
|
cleanly instead of looping forever against a decommissioned identity.
|
|
The retired-agent check runs before any "not found" string match so
|
|
it can never be masked by a sibling error branch.
|
|
operationId: agentHeartbeat
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
version:
|
|
type: string
|
|
hostname:
|
|
type: string
|
|
os:
|
|
type: string
|
|
architecture:
|
|
type: string
|
|
ip_address:
|
|
type: string
|
|
responses:
|
|
"200":
|
|
description: Heartbeat recorded
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"410":
|
|
description: |
|
|
I-004: the agent has been soft-retired. The agent process should
|
|
treat this as a terminal signal and shut down cleanly.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorResponse"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/agents/{id}/csr:
|
|
post:
|
|
tags: [Agents]
|
|
summary: Submit CSR
|
|
description: Agent submits a PEM-encoded CSR for signing. Used in agent keygen mode.
|
|
operationId: agentSubmitCSR
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [csr_pem]
|
|
properties:
|
|
csr_pem:
|
|
type: string
|
|
description: PEM-encoded certificate signing request
|
|
certificate_id:
|
|
type: string
|
|
responses:
|
|
"202":
|
|
description: CSR accepted
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/agents/{id}/certificates/{cert_id}:
|
|
get:
|
|
tags: [Agents]
|
|
summary: Pick up signed certificate
|
|
description: Agent retrieves the signed certificate PEM after CSR signing completes.
|
|
operationId: agentPickupCertificate
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
- name: cert_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
"200":
|
|
description: Certificate PEM
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
certificate_pem:
|
|
type: string
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/agents/{id}/work:
|
|
get:
|
|
tags: [Agents]
|
|
summary: Get pending work
|
|
description: Returns pending deployment and AwaitingCSR jobs for the agent.
|
|
operationId: agentGetWork
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Work items
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
jobs:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/WorkItem"
|
|
count:
|
|
type: integer
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/agents/{id}/jobs/{job_id}/status:
|
|
post:
|
|
tags: [Agents]
|
|
summary: Report job status
|
|
description: Agent reports completion or failure of an assigned job.
|
|
operationId: agentReportJobStatus
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
- name: job_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [status]
|
|
properties:
|
|
status:
|
|
type: string
|
|
error:
|
|
type: string
|
|
responses:
|
|
"200":
|
|
description: Status updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Jobs ────────────────────────────────────────────────────────────
|
|
/api/v1/jobs:
|
|
get:
|
|
tags: [Jobs]
|
|
summary: List jobs
|
|
operationId: listJobs
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
- name: status
|
|
in: query
|
|
schema:
|
|
$ref: "#/components/schemas/JobStatus"
|
|
- name: type
|
|
in: query
|
|
schema:
|
|
$ref: "#/components/schemas/JobType"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of jobs
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/Job"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/jobs/{id}:
|
|
get:
|
|
tags: [Jobs]
|
|
summary: Get job
|
|
operationId: getJob
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Job details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Job"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/jobs/{id}/cancel:
|
|
post:
|
|
tags: [Jobs]
|
|
summary: Cancel job
|
|
operationId: cancelJob
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Job cancelled
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/jobs/{id}/approve:
|
|
post:
|
|
tags: [Jobs]
|
|
summary: Approve job
|
|
description: Approves a job in AwaitingApproval state.
|
|
operationId: approveJob
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Job approved
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/jobs/{id}/reject:
|
|
post:
|
|
tags: [Jobs]
|
|
summary: Reject job
|
|
description: Rejects a job in AwaitingApproval state with an optional reason.
|
|
operationId: rejectJob
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
reason:
|
|
type: string
|
|
responses:
|
|
"200":
|
|
description: Job rejected
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/jobs/{id}/verify:
|
|
post:
|
|
tags: [Verification]
|
|
summary: Record post-deployment verification result
|
|
description: |
|
|
Agents submit the result of probing a deployed certificate's live TLS endpoint.
|
|
Compares the served certificate's SHA-256 fingerprint against the expected
|
|
fingerprint. Best-effort: failures are recorded on the job but do not roll
|
|
back the deployment.
|
|
operationId: verifyDeployment
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/VerifyDeploymentRequest"
|
|
responses:
|
|
"200":
|
|
description: Verification result recorded
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
job_id:
|
|
type: string
|
|
verified:
|
|
type: boolean
|
|
verified_at:
|
|
type: string
|
|
format: date-time
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/jobs/{id}/verification:
|
|
get:
|
|
tags: [Verification]
|
|
summary: Get post-deployment verification status
|
|
description: |
|
|
Returns the stored verification result for a deployment job — expected
|
|
and observed SHA-256 fingerprints, verified flag, and timestamp.
|
|
operationId: getJobVerification
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Verification result for the job
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/VerificationResult"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Policies ────────────────────────────────────────────────────────
|
|
/api/v1/policies:
|
|
get:
|
|
tags: [Policies]
|
|
summary: List policies
|
|
operationId: listPolicies
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of policies
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/PolicyRule"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Policies]
|
|
summary: Create policy
|
|
operationId: createPolicy
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/PolicyRule"
|
|
responses:
|
|
"201":
|
|
description: Policy created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/PolicyRule"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/policies/{id}:
|
|
get:
|
|
tags: [Policies]
|
|
summary: Get policy
|
|
operationId: getPolicy
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Policy details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/PolicyRule"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [Policies]
|
|
summary: Update policy
|
|
operationId: updatePolicy
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/PolicyRule"
|
|
responses:
|
|
"200":
|
|
description: Policy updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/PolicyRule"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Policies]
|
|
summary: Delete policy
|
|
operationId: deletePolicy
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Policy deleted
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/policies/{id}/violations:
|
|
get:
|
|
tags: [Policies]
|
|
summary: List policy violations
|
|
operationId: listPolicyViolations
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of violations
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/PolicyViolation"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Renewal Policies ────────────────────────────────────────────────
|
|
# G-1: lifecycle policies (rp-* ids, table renewal_policies). DISTINCT from
|
|
# /api/v1/policies above, which returns compliance rules (pol-* ids, table
|
|
# policy_rules). `managed_certificates.renewal_policy_id` FK points at
|
|
# renewal_policies(id) — populating that dropdown from /api/v1/policies
|
|
# caused 23503 FK violations; hence this endpoint.
|
|
/api/v1/renewal-policies:
|
|
get:
|
|
tags: [RenewalPolicies]
|
|
summary: List renewal policies
|
|
operationId: listRenewalPolicies
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of renewal policies
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/RenewalPolicy"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [RenewalPolicies]
|
|
summary: Create renewal policy
|
|
operationId: createRenewalPolicy
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/RenewalPolicyCreateRequest"
|
|
responses:
|
|
"201":
|
|
description: Renewal policy created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/RenewalPolicy"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"409":
|
|
description: Duplicate policy name
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Error"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/renewal-policies/{id}:
|
|
get:
|
|
tags: [RenewalPolicies]
|
|
summary: Get renewal policy
|
|
operationId: getRenewalPolicy
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Renewal policy details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/RenewalPolicy"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [RenewalPolicies]
|
|
summary: Update renewal policy
|
|
operationId: updateRenewalPolicy
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/RenewalPolicyUpdateRequest"
|
|
responses:
|
|
"200":
|
|
description: Renewal policy updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/RenewalPolicy"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"409":
|
|
description: Duplicate policy name
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Error"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [RenewalPolicies]
|
|
summary: Delete renewal policy
|
|
operationId: deleteRenewalPolicy
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Renewal policy deleted
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"409":
|
|
description: Policy in use by one or more certificates (FK restrict)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Error"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Profiles ────────────────────────────────────────────────────────
|
|
/api/v1/profiles:
|
|
get:
|
|
tags: [Profiles]
|
|
summary: List profiles
|
|
operationId: listProfiles
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of profiles
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/CertificateProfile"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Profiles]
|
|
summary: Create profile
|
|
operationId: createProfile
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/CertificateProfile"
|
|
responses:
|
|
"201":
|
|
description: Profile created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/CertificateProfile"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/profiles/{id}:
|
|
get:
|
|
tags: [Profiles]
|
|
summary: Get profile
|
|
operationId: getProfile
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Profile details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/CertificateProfile"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [Profiles]
|
|
summary: Update profile
|
|
operationId: updateProfile
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/CertificateProfile"
|
|
responses:
|
|
"200":
|
|
description: Profile updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/CertificateProfile"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Profiles]
|
|
summary: Delete profile
|
|
operationId: deleteProfile
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Profile deleted
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Teams ───────────────────────────────────────────────────────────
|
|
/api/v1/teams:
|
|
get:
|
|
tags: [Teams]
|
|
summary: List teams
|
|
operationId: listTeams
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of teams
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/Team"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Teams]
|
|
summary: Create team
|
|
operationId: createTeam
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Team"
|
|
responses:
|
|
"201":
|
|
description: Team created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Team"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/teams/{id}:
|
|
get:
|
|
tags: [Teams]
|
|
summary: Get team
|
|
operationId: getTeam
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Team details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Team"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [Teams]
|
|
summary: Update team
|
|
operationId: updateTeam
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Team"
|
|
responses:
|
|
"200":
|
|
description: Team updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Team"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Teams]
|
|
summary: Delete team
|
|
operationId: deleteTeam
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Team deleted
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Owners ──────────────────────────────────────────────────────────
|
|
/api/v1/owners:
|
|
get:
|
|
tags: [Owners]
|
|
summary: List owners
|
|
operationId: listOwners
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of owners
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/Owner"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Owners]
|
|
summary: Create owner
|
|
operationId: createOwner
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Owner"
|
|
responses:
|
|
"201":
|
|
description: Owner created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Owner"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/owners/{id}:
|
|
get:
|
|
tags: [Owners]
|
|
summary: Get owner
|
|
operationId: getOwner
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Owner details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Owner"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [Owners]
|
|
summary: Update owner
|
|
operationId: updateOwner
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Owner"
|
|
responses:
|
|
"200":
|
|
description: Owner updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/Owner"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Owners]
|
|
summary: Delete owner
|
|
operationId: deleteOwner
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Owner deleted
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Agent Groups ───────────────────────────────────────────────────
|
|
/api/v1/agent-groups:
|
|
get:
|
|
tags: [Agent Groups]
|
|
summary: List agent groups
|
|
operationId: listAgentGroups
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: Paginated list of agent groups
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/AgentGroup"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Agent Groups]
|
|
summary: Create agent group
|
|
operationId: createAgentGroup
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/AgentGroup"
|
|
responses:
|
|
"201":
|
|
description: Agent group created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/AgentGroup"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/agent-groups/{id}:
|
|
get:
|
|
tags: [Agent Groups]
|
|
summary: Get agent group
|
|
operationId: getAgentGroup
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Agent group details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/AgentGroup"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [Agent Groups]
|
|
summary: Update agent group
|
|
operationId: updateAgentGroup
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/AgentGroup"
|
|
responses:
|
|
"200":
|
|
description: Agent group updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/AgentGroup"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Agent Groups]
|
|
summary: Delete agent group
|
|
operationId: deleteAgentGroup
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Agent group deleted
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/agent-groups/{id}/members:
|
|
get:
|
|
tags: [Agent Groups]
|
|
summary: List agent group members
|
|
description: Returns agents matching the group's dynamic criteria plus manually included members.
|
|
operationId: listAgentGroupMembers
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: List of member agents
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/Agent"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Audit ───────────────────────────────────────────────────────────
|
|
/api/v1/audit:
|
|
get:
|
|
tags: [Audit]
|
|
summary: List audit events
|
|
description: |
|
|
Bundle 1 Phase 8 adds the optional `category` query parameter
|
|
for auditor-role filtering. Allowed values: `cert_lifecycle`
|
|
(cert/agent/deployment events), `auth` (role/key/bootstrap
|
|
mutations), `config` (issuer/target/settings edits). Omitting
|
|
the parameter returns every category.
|
|
|
|
P-H2 closure (frontend-design-audit 2026-05-14) adds the
|
|
optional `since` / `until` time-range query parameters. Both
|
|
accept RFC3339 timestamps (e.g. `2026-04-01T00:00:00Z`).
|
|
Either bound can be omitted to leave that side open-ended.
|
|
Combined with `category`, they let auditor-role clients query
|
|
"auth events from yesterday" without a separate endpoint.
|
|
|
|
Note on naming: this endpoint uses `since` / `until` to match
|
|
the existing MCP `certctl_audit_list_with_category` tool's
|
|
published contract. The sibling `/api/v1/audit/export`
|
|
endpoint uses `from` / `to` for compliance-window semantics
|
|
(required, ≤ 90-day range, NDJSON streaming); the two
|
|
endpoints share data but the names reflect the different
|
|
param semantics.
|
|
operationId: listAuditEvents
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
- in: query
|
|
name: category
|
|
schema:
|
|
type: string
|
|
enum: [cert_lifecycle, auth, config]
|
|
description: Filter to events of this event_category. (Bundle 1 Phase 8)
|
|
- in: query
|
|
name: since
|
|
schema:
|
|
type: string
|
|
format: date-time
|
|
description: |
|
|
Lower bound on `timestamp` (RFC3339). Inclusive.
|
|
Open-ended when omitted. (P-H2 2026-05-14)
|
|
- in: query
|
|
name: until
|
|
schema:
|
|
type: string
|
|
format: date-time
|
|
description: |
|
|
Upper bound on `timestamp` (RFC3339). Inclusive.
|
|
Open-ended when omitted. Must be after `since` if both
|
|
are set. (P-H2 2026-05-14)
|
|
responses:
|
|
"200":
|
|
description: Paginated list of audit events
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/AuditEvent"
|
|
"400":
|
|
description: |
|
|
Invalid `category` value, malformed RFC3339 `since`/`until`,
|
|
or `until` not after `since`.
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/audit/{id}:
|
|
get:
|
|
tags: [Audit]
|
|
summary: Get audit event
|
|
operationId: getAuditEvent
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Audit event details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/AuditEvent"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Notifications ──────────────────────────────────────────────────
|
|
/api/v1/approvals:
|
|
get:
|
|
tags: [Approvals]
|
|
summary: List approval requests
|
|
description: |
|
|
Rank 7 issuance approval-workflow primitive. Returns paginated approval
|
|
requests, optionally filtered by ?state= (pending/approved/rejected/expired),
|
|
?certificate_id=, or ?requested_by=. Empty filters return the unfiltered
|
|
list (default page=1, per_page=50).
|
|
operationId: listApprovalRequests
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
- name: state
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
enum: [pending, approved, rejected, expired]
|
|
- name: certificate_id
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
- name: requested_by
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
responses:
|
|
"200":
|
|
description: Paginated list of approval requests
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/ApprovalRequest"
|
|
page:
|
|
type: integer
|
|
per_page:
|
|
type: integer
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/approvals/{id}:
|
|
get:
|
|
tags: [Approvals]
|
|
summary: Get approval request
|
|
description: Returns a single approval request by ID.
|
|
operationId: getApprovalRequest
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Approval request details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ApprovalRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/approvals/{id}/approve:
|
|
post:
|
|
tags: [Approvals]
|
|
summary: Approve a pending approval request
|
|
description: |
|
|
Transitions a pending request to approved AND transitions the linked
|
|
Job from AwaitingApproval to Pending so the scheduler picks it up.
|
|
RBAC: the authenticated actor extracted via the auth middleware MUST
|
|
differ from the request's requested_by — a same-actor self-approval
|
|
returns HTTP 403 with the substring `two-person integrity` in the
|
|
body. This is the load-bearing two-person integrity contract;
|
|
compliance auditors (PCI-DSS 6.4.5, NIST 800-53 SA-15, SOC 2 CC6.1)
|
|
pattern-match against this code path.
|
|
operationId: approveApprovalRequest
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: false
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
note:
|
|
type: string
|
|
description: Optional reason text for the audit trail.
|
|
responses:
|
|
"200":
|
|
description: Approval recorded; linked Job transitioned to Pending
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
decided_by: { type: string }
|
|
action: { type: string, enum: [approved] }
|
|
"401":
|
|
description: Authentication required
|
|
"403":
|
|
description: Same-actor self-approval blocked by two-person integrity contract
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"409":
|
|
description: Request already decided (terminal state)
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/approvals/{id}/reject:
|
|
post:
|
|
tags: [Approvals]
|
|
summary: Reject a pending approval request
|
|
description: |
|
|
Transitions a pending request to rejected AND cancels the linked
|
|
Job. Same-actor RBAC contract as approve. The job's error_message
|
|
is populated with the supplied note for audit continuity.
|
|
operationId: rejectApprovalRequest
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: false
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
note:
|
|
type: string
|
|
description: Optional reason text for the audit trail.
|
|
responses:
|
|
"200":
|
|
description: Rejection recorded; linked Job transitioned to Cancelled
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
decided_by: { type: string }
|
|
action: { type: string, enum: [rejected] }
|
|
"401":
|
|
description: Authentication required
|
|
"403":
|
|
description: Same-actor self-rejection blocked by two-person integrity contract
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"409":
|
|
description: Request already decided (terminal state)
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/issuers/{id}/intermediates:
|
|
post:
|
|
tags: [IntermediateCAs]
|
|
summary: Create a root or child intermediate CA under the issuer
|
|
description: |
|
|
Admin-gated. Discriminator on body shape: when parent_ca_id is
|
|
empty AND root_cert_pem + key_driver_id are present, the
|
|
endpoint registers an operator-supplied root CA. Otherwise it
|
|
signs a child sub-CA cert under the named parent (RFC 5280
|
|
§4.2.1.9 path-length tightening + §4.2.1.10 NameConstraints
|
|
subset semantics enforced at the service layer).
|
|
operationId: createIntermediateCA
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name]
|
|
properties:
|
|
name: { type: string }
|
|
parent_ca_id:
|
|
type: string
|
|
description: Empty for root registration; non-empty for child signing
|
|
root_cert_pem:
|
|
type: string
|
|
description: Operator-supplied root cert PEM (root path only)
|
|
key_driver_id:
|
|
type: string
|
|
description: signer.Driver reference for the root key (root path only)
|
|
subject:
|
|
type: object
|
|
description: Distinguished name for child CA (child path only)
|
|
algorithm:
|
|
type: string
|
|
description: Signing algorithm for child key (default ECDSA-P256)
|
|
ttl_days:
|
|
type: integer
|
|
path_len_constraint:
|
|
type: integer
|
|
nullable: true
|
|
name_constraints:
|
|
type: array
|
|
items: { type: object }
|
|
ocsp_responder_url:
|
|
type: string
|
|
metadata:
|
|
type: object
|
|
responses:
|
|
"201":
|
|
description: IntermediateCA row created
|
|
"400":
|
|
description: Validation failed (RFC 5280 violations, malformed cert PEM, missing root bundle)
|
|
"401":
|
|
description: Authentication required
|
|
"403":
|
|
description: Admin role required
|
|
"409":
|
|
description: Parent CA not in active state
|
|
"404":
|
|
description: Parent CA not found
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
get:
|
|
tags: [IntermediateCAs]
|
|
summary: List the CA hierarchy for an issuer
|
|
description: |
|
|
Admin-gated. Returns the flat list of every IntermediateCA row
|
|
for the issuer, ordered by created_at. The caller renders the
|
|
tree from each row's parent_ca_id (nil = root).
|
|
operationId: listIntermediateCAs
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Flat list of CA rows
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items: { type: object }
|
|
"401":
|
|
description: Authentication required
|
|
"403":
|
|
description: Admin role required
|
|
|
|
/api/v1/intermediates/{id}:
|
|
get:
|
|
tags: [IntermediateCAs]
|
|
summary: Get a single intermediate CA by ID
|
|
operationId: getIntermediateCA
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: IntermediateCA row
|
|
"401":
|
|
description: Authentication required
|
|
"403":
|
|
description: Admin role required
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
|
|
/api/v1/intermediates/{id}/retire:
|
|
post:
|
|
tags: [IntermediateCAs]
|
|
summary: Retire an intermediate CA (two-phase drain)
|
|
description: |
|
|
Admin-gated. Two-phase: first call (confirm=false) transitions
|
|
active to retiring (the CA stops issuing new children but
|
|
existing children continue). Second call (confirm=true)
|
|
transitions retiring to retired (terminal). Refuses the
|
|
terminal transition if the CA still has active children —
|
|
drain-first semantics.
|
|
operationId: retireIntermediateCA
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: false
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
note: { type: string }
|
|
confirm: { type: boolean, default: false }
|
|
responses:
|
|
"200":
|
|
description: Retire transition recorded
|
|
"401":
|
|
description: Authentication required
|
|
"403":
|
|
description: Admin role required
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"409":
|
|
description: CA still has active children; drain them first
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/notifications:
|
|
get:
|
|
tags: [Notifications]
|
|
summary: List notifications
|
|
operationId: listNotifications
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
- name: status
|
|
in: query
|
|
required: false
|
|
description: |
|
|
Filter by lifecycle status. I-005: `dead` powers the Dead letter
|
|
tab on the GUI; empty/omitted returns the default all-statuses
|
|
listing to preserve pre-I-005 behavior.
|
|
schema:
|
|
type: string
|
|
enum: [pending, sent, failed, dead, read]
|
|
responses:
|
|
"200":
|
|
description: Paginated list of notifications
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/NotificationEvent"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/notifications/{id}:
|
|
get:
|
|
tags: [Notifications]
|
|
summary: Get notification
|
|
operationId: getNotification
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Notification details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/NotificationEvent"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/notifications/{id}/read:
|
|
post:
|
|
tags: [Notifications]
|
|
summary: Mark notification as read
|
|
operationId: markNotificationAsRead
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Marked as read
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/notifications/{id}/requeue:
|
|
post:
|
|
tags: [Notifications]
|
|
summary: Requeue a dead notification
|
|
description: |
|
|
I-005: flip a notification from the `dead` dead-letter queue back to
|
|
`pending` so the retry sweep (default 2 minutes) picks it up on its
|
|
next tick. Used by operators after fixing the underlying delivery
|
|
failure (SMTP config, webhook endpoint, etc.). Clears `next_retry_at`
|
|
and resets the `retry_count` budget; `last_error` is preserved for
|
|
audit continuity.
|
|
operationId: requeueNotification
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Requeued
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"405":
|
|
description: Method not allowed (POST only)
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Stats ───────────────────────────────────────────────────────────
|
|
/api/v1/stats/summary:
|
|
get:
|
|
tags: [Stats]
|
|
summary: Dashboard summary
|
|
operationId: getDashboardSummary
|
|
responses:
|
|
"200":
|
|
description: High-level system metrics
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DashboardSummary"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/stats/certificates-by-status:
|
|
get:
|
|
tags: [Stats]
|
|
summary: Certificate status breakdown
|
|
operationId: getCertificatesByStatus
|
|
responses:
|
|
"200":
|
|
description: Certificate counts by status
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status_counts:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
count:
|
|
type: integer
|
|
format: int64
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/stats/expiration-timeline:
|
|
get:
|
|
tags: [Stats]
|
|
summary: Expiration timeline
|
|
operationId: getExpirationTimeline
|
|
parameters:
|
|
- name: days
|
|
in: query
|
|
schema:
|
|
type: integer
|
|
default: 30
|
|
minimum: 1
|
|
maximum: 365
|
|
responses:
|
|
"200":
|
|
description: Certificates expiring per day
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
buckets:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
date:
|
|
type: string
|
|
format: date
|
|
count:
|
|
type: integer
|
|
format: int64
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/stats/job-trends:
|
|
get:
|
|
tags: [Stats]
|
|
summary: Job success/failure trends
|
|
operationId: getJobTrends
|
|
parameters:
|
|
- name: days
|
|
in: query
|
|
schema:
|
|
type: integer
|
|
default: 30
|
|
minimum: 1
|
|
maximum: 365
|
|
responses:
|
|
"200":
|
|
description: Job trends per day
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
trends:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
date:
|
|
type: string
|
|
format: date
|
|
completed:
|
|
type: integer
|
|
format: int64
|
|
failed:
|
|
type: integer
|
|
format: int64
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/stats/issuance-rate:
|
|
get:
|
|
tags: [Stats]
|
|
summary: Certificate issuance rate
|
|
operationId: getIssuanceRate
|
|
parameters:
|
|
- name: days
|
|
in: query
|
|
schema:
|
|
type: integer
|
|
default: 30
|
|
minimum: 1
|
|
maximum: 365
|
|
responses:
|
|
"200":
|
|
description: Issuance count per day
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
rate:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
date:
|
|
type: string
|
|
format: date
|
|
count:
|
|
type: integer
|
|
format: int64
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Metrics ─────────────────────────────────────────────────────────
|
|
/api/v1/metrics:
|
|
get:
|
|
tags: [Metrics]
|
|
summary: System metrics
|
|
description: JSON metrics snapshot with gauges, counters, and uptime. See also /api/v1/metrics/prometheus for Prometheus exposition format.
|
|
operationId: getMetrics
|
|
responses:
|
|
"200":
|
|
description: Metrics snapshot
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/MetricsResponse"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Prometheus Metrics (M22) ──────────────────────────────────────
|
|
/api/v1/metrics/prometheus:
|
|
get:
|
|
tags: [Metrics]
|
|
summary: Prometheus metrics
|
|
description: |
|
|
Prometheus exposition format metrics. Compatible with Prometheus, Grafana Agent,
|
|
Datadog Agent, Victoria Metrics, and any OpenMetrics scraper.
|
|
Returns 11 metrics with certctl_ prefix (8 gauges, 2 counters, 1 info).
|
|
operationId: getPrometheusMetrics
|
|
responses:
|
|
"200":
|
|
description: Prometheus text format
|
|
content:
|
|
text/plain:
|
|
schema:
|
|
type: string
|
|
description: "Prometheus exposition format (text/plain; version=0.0.4)"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Certificate Deployments (M20) ─────────────────────────────────
|
|
/api/v1/certificates/{id}/deployments:
|
|
get:
|
|
tags: [Certificates]
|
|
summary: List certificate deployments
|
|
description: Returns deployment targets associated with this certificate.
|
|
operationId: getCertificateDeployments
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Deployment targets for this certificate
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/DeploymentTarget"
|
|
total:
|
|
type: integer
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Discovery (M18b) ─────────────────────────────────────────────
|
|
/api/v1/agents/{id}/discoveries:
|
|
post:
|
|
tags: [Discovery]
|
|
summary: Submit discovery report
|
|
description: |
|
|
Agent submits a batch of discovered certificates from filesystem scanning.
|
|
Server deduplicates by (fingerprint, agent_id, source_path) and records scan metadata.
|
|
operationId: submitDiscoveryReport
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DiscoveryReport"
|
|
responses:
|
|
"202":
|
|
description: Report accepted and processed
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DiscoveryScan"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/discovered-certificates:
|
|
get:
|
|
tags: [Discovery]
|
|
summary: List discovered certificates
|
|
description: Returns discovered certificates with optional filters by agent and triage status.
|
|
operationId: listDiscoveredCertificates
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
- name: agent_id
|
|
in: query
|
|
schema:
|
|
type: string
|
|
description: Filter by discovering agent
|
|
- name: status
|
|
in: query
|
|
schema:
|
|
type: string
|
|
enum: [Unmanaged, Managed, Dismissed]
|
|
description: Filter by triage status
|
|
responses:
|
|
"200":
|
|
description: Paginated list of discovered certificates
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/DiscoveredCertificate"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/discovered-certificates/{id}:
|
|
get:
|
|
tags: [Discovery]
|
|
summary: Get discovered certificate
|
|
description: Returns a single discovered certificate by ID.
|
|
operationId: getDiscoveredCertificate
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Discovered certificate details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DiscoveredCertificate"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/discovered-certificates/{id}/claim:
|
|
post:
|
|
tags: [Discovery]
|
|
summary: Claim discovered certificate
|
|
description: Links a discovered certificate to an existing managed certificate. Changes status to Managed.
|
|
operationId: claimDiscoveredCertificate
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [managed_certificate_id]
|
|
properties:
|
|
managed_certificate_id:
|
|
type: string
|
|
description: ID of the managed certificate to link to
|
|
responses:
|
|
"200":
|
|
description: Certificate claimed
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusMessageResponse"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/discovered-certificates/{id}/dismiss:
|
|
post:
|
|
tags: [Discovery]
|
|
summary: Dismiss discovered certificate
|
|
description: Marks a discovered certificate as dismissed (excluded from triage queue).
|
|
operationId: dismissDiscoveredCertificate
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Certificate dismissed
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusMessageResponse"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/discovery-scans:
|
|
get:
|
|
tags: [Discovery]
|
|
summary: List discovery scans
|
|
description: Returns history of discovery scan executions with optional agent filter.
|
|
operationId: listDiscoveryScans
|
|
parameters:
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
- name: agent_id
|
|
in: query
|
|
schema:
|
|
type: string
|
|
description: Filter by agent ID
|
|
responses:
|
|
"200":
|
|
description: Paginated list of discovery scans
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/DiscoveryScan"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/discovery-summary:
|
|
get:
|
|
tags: [Discovery]
|
|
summary: Discovery status summary
|
|
description: Returns aggregate counts of discovered certificates by triage status.
|
|
operationId: getDiscoverySummary
|
|
responses:
|
|
"200":
|
|
description: Status counts
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
Unmanaged:
|
|
type: integer
|
|
Managed:
|
|
type: integer
|
|
Dismissed:
|
|
type: integer
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Network Scan Targets (M21) ───────────────────────────────────
|
|
/api/v1/network-scan-targets:
|
|
get:
|
|
tags: [Network Scan]
|
|
summary: List network scan targets
|
|
description: Returns all configured network scan targets with CIDR ranges and ports.
|
|
operationId: listNetworkScanTargets
|
|
responses:
|
|
"200":
|
|
description: List of network scan targets
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- $ref: "#/components/schemas/PaginationEnvelope"
|
|
- type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/NetworkScanTarget"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [Network Scan]
|
|
summary: Create network scan target
|
|
description: |
|
|
Creates a new network scan target. CIDR ranges are validated and capped at /20
|
|
(4096 IPs max per CIDR) to prevent accidental huge scans.
|
|
operationId: createNetworkScanTarget
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/NetworkScanTargetCreate"
|
|
responses:
|
|
"201":
|
|
description: Target created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/NetworkScanTarget"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/network-scan-targets/{id}:
|
|
get:
|
|
tags: [Network Scan]
|
|
summary: Get network scan target
|
|
description: Returns a single network scan target by ID.
|
|
operationId: getNetworkScanTarget
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Network scan target details
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/NetworkScanTarget"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
put:
|
|
tags: [Network Scan]
|
|
summary: Update network scan target
|
|
description: Updates an existing network scan target.
|
|
operationId: updateNetworkScanTarget
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/NetworkScanTargetCreate"
|
|
responses:
|
|
"200":
|
|
description: Target updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/NetworkScanTarget"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
delete:
|
|
tags: [Network Scan]
|
|
summary: Delete network scan target
|
|
description: Deletes a network scan target.
|
|
operationId: deleteNetworkScanTarget
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Target deleted
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/network-scan-targets/{id}/scan:
|
|
post:
|
|
tags: [Network Scan]
|
|
summary: Trigger network scan
|
|
description: |
|
|
Triggers an immediate scan of the specified target. Scans all configured CIDRs and ports
|
|
concurrently (50 goroutines). Results feed into the discovery pipeline for deduplication.
|
|
operationId: triggerNetworkScan
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"202":
|
|
description: Scan completed with certificates found
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/DiscoveryScan"
|
|
"200":
|
|
description: Scan completed, no certificates found
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusMessageResponse"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Health Monitoring ─────────────────────────────────────────────
|
|
/api/v1/health-checks:
|
|
get:
|
|
tags: [Health Monitoring]
|
|
summary: List endpoint health checks
|
|
description: |
|
|
Lists all TLS endpoint health checks with optional filtering by status, certificate, or network scan target.
|
|
Includes current status, last probe results, and probe history summary.
|
|
operationId: listHealthChecks
|
|
parameters:
|
|
- name: status
|
|
in: query
|
|
schema:
|
|
type: string
|
|
enum: [Healthy, Degraded, Down, CertMismatch]
|
|
description: Filter by health status
|
|
- name: certificate_id
|
|
in: query
|
|
schema:
|
|
type: string
|
|
description: Filter by certificate ID
|
|
- name: network_scan_target_id
|
|
in: query
|
|
schema:
|
|
type: string
|
|
description: Filter by network scan target ID
|
|
- name: enabled
|
|
in: query
|
|
schema:
|
|
type: boolean
|
|
description: Filter by enabled/disabled state
|
|
- $ref: "#/components/parameters/page"
|
|
- $ref: "#/components/parameters/per_page"
|
|
responses:
|
|
"200":
|
|
description: List of health checks
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/EndpointHealthCheck"
|
|
total:
|
|
type: integer
|
|
page:
|
|
type: integer
|
|
per_page:
|
|
type: integer
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
post:
|
|
tags: [Health Monitoring]
|
|
summary: Create health check
|
|
description: Creates a new manual health check for an endpoint.
|
|
operationId: createHealthCheck
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [endpoint, check_interval_seconds]
|
|
properties:
|
|
endpoint:
|
|
type: string
|
|
description: "host:port to monitor"
|
|
example: "api.example.com:443"
|
|
expected_fingerprint:
|
|
type: string
|
|
description: Expected certificate SHA-256 fingerprint (optional)
|
|
check_interval_seconds:
|
|
type: integer
|
|
minimum: 30
|
|
description: Probe frequency in seconds (default 300)
|
|
timeout_ms:
|
|
type: integer
|
|
description: TLS connection timeout in milliseconds
|
|
responses:
|
|
"201":
|
|
description: Health check created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/EndpointHealthCheck"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/health-checks/summary:
|
|
get:
|
|
tags: [Health Monitoring]
|
|
summary: Health check summary
|
|
description: Returns aggregate status counts for all health checks.
|
|
operationId: getHealthCheckSummary
|
|
responses:
|
|
"200":
|
|
description: Health check summary
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
healthy:
|
|
type: integer
|
|
degraded:
|
|
type: integer
|
|
down:
|
|
type: integer
|
|
cert_mismatch:
|
|
type: integer
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/health-checks/{id}:
|
|
get:
|
|
tags: [Health Monitoring]
|
|
summary: Get health check
|
|
operationId: getHealthCheck
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"200":
|
|
description: Health check detail
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/EndpointHealthCheck"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
put:
|
|
tags: [Health Monitoring]
|
|
summary: Update health check
|
|
description: Update thresholds, interval, or expected fingerprint.
|
|
operationId: updateHealthCheck
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
expected_fingerprint:
|
|
type: string
|
|
check_interval_seconds:
|
|
type: integer
|
|
timeout_ms:
|
|
type: integer
|
|
enabled:
|
|
type: boolean
|
|
responses:
|
|
"200":
|
|
description: Health check updated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/EndpointHealthCheck"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
delete:
|
|
tags: [Health Monitoring]
|
|
summary: Delete health check
|
|
operationId: deleteHealthCheck
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
responses:
|
|
"204":
|
|
description: Health check deleted
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/health-checks/{id}/history:
|
|
get:
|
|
tags: [Health Monitoring]
|
|
summary: Get probe history
|
|
description: Returns historical probe records with status, response times, and errors.
|
|
operationId: getHealthCheckHistory
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
- name: limit
|
|
in: query
|
|
schema:
|
|
type: integer
|
|
default: 100
|
|
minimum: 1
|
|
maximum: 1000
|
|
description: Max number of records to return
|
|
responses:
|
|
"200":
|
|
description: Probe history
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/HealthHistoryEntry"
|
|
total:
|
|
type: integer
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/health-checks/{id}/acknowledge:
|
|
post:
|
|
tags: [Health Monitoring]
|
|
summary: Acknowledge incident
|
|
description: Mark a health check incident as acknowledged by the operator.
|
|
operationId: acknowledgeHealthCheckIncident
|
|
parameters:
|
|
- $ref: "#/components/parameters/resourceId"
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
acknowledged_by:
|
|
type: string
|
|
description: Operator name or ID
|
|
responses:
|
|
"200":
|
|
description: Incident acknowledged
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/EndpointHealthCheck"
|
|
"404":
|
|
$ref: "#/components/responses/NotFound"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── Digest ────────────────────────────────────────────────────────
|
|
/api/v1/digest/preview:
|
|
get:
|
|
tags: [Digest]
|
|
summary: Preview digest email
|
|
description: |
|
|
Returns an HTML preview of the scheduled certificate digest email.
|
|
This includes a summary of certificate status, pending jobs, and expiring certificates.
|
|
operationId: previewDigest
|
|
responses:
|
|
"200":
|
|
description: HTML digest email preview
|
|
content:
|
|
text/html:
|
|
schema:
|
|
type: string
|
|
example: "<html>...</html>"
|
|
"503":
|
|
description: Digest service not configured
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusMessageResponse"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/api/v1/digest/send:
|
|
post:
|
|
tags: [Digest]
|
|
summary: Send digest email
|
|
description: |
|
|
Triggers immediate sending of the certificate digest email to configured recipients.
|
|
If no explicit recipients are configured, sends to certificate owners.
|
|
operationId: sendDigest
|
|
responses:
|
|
"200":
|
|
description: Digest sent successfully
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusMessageResponse"
|
|
"503":
|
|
description: Digest service not configured
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/StatusMessageResponse"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── EST (RFC 7030) ────────────────────────────────────────────────
|
|
/.well-known/est/cacerts:
|
|
get:
|
|
tags: [EST]
|
|
summary: EST CA certificates distribution
|
|
description: |
|
|
Returns the CA certificate chain used to verify certctl-issued certificates.
|
|
Response is a base64-encoded degenerate PKCS#7 SignedData (certs-only) per
|
|
RFC 7030 §4.1.3.
|
|
operationId: estCACerts
|
|
security: []
|
|
responses:
|
|
"200":
|
|
description: Base64-encoded PKCS#7 certs-only structure
|
|
headers:
|
|
Content-Transfer-Encoding:
|
|
schema:
|
|
type: string
|
|
example: base64
|
|
content:
|
|
application/pkcs7-mime:
|
|
schema:
|
|
type: string
|
|
format: byte
|
|
description: "Base64-encoded PKCS#7 (smime-type=certs-only)"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/.well-known/est/simpleenroll:
|
|
post:
|
|
tags: [EST]
|
|
summary: EST simple enrollment
|
|
description: |
|
|
Enrolls a new certificate from a PKCS#10 CSR per RFC 7030 §4.2.1.
|
|
The CSR MAY be supplied as base64-encoded DER (EST standard wire format)
|
|
or as PEM for convenience. Returns a base64-encoded PKCS#7 certs-only
|
|
structure containing the issued certificate.
|
|
operationId: estSimpleEnroll
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
description: "Base64-encoded DER PKCS#10 CSR, or PEM-encoded CSR"
|
|
content:
|
|
application/pkcs10:
|
|
schema:
|
|
type: string
|
|
format: byte
|
|
responses:
|
|
"200":
|
|
description: Base64-encoded PKCS#7 cert-only response with issued certificate
|
|
headers:
|
|
Content-Transfer-Encoding:
|
|
schema:
|
|
type: string
|
|
example: base64
|
|
content:
|
|
application/pkcs7-mime:
|
|
schema:
|
|
type: string
|
|
format: byte
|
|
description: "Base64-encoded PKCS#7 (smime-type=certs-only)"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"405":
|
|
description: Method not allowed (only POST accepted)
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/.well-known/est/simplereenroll:
|
|
post:
|
|
tags: [EST]
|
|
summary: EST simple re-enrollment
|
|
description: |
|
|
Re-enrolls an existing certificate (same as simpleenroll in certctl's
|
|
implementation — re-enrollment is treated as a fresh issuance) per
|
|
RFC 7030 §4.2.2.
|
|
operationId: estSimpleReEnroll
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
description: "Base64-encoded DER PKCS#10 CSR, or PEM-encoded CSR"
|
|
content:
|
|
application/pkcs10:
|
|
schema:
|
|
type: string
|
|
format: byte
|
|
responses:
|
|
"200":
|
|
description: Base64-encoded PKCS#7 cert-only response with re-issued certificate
|
|
headers:
|
|
Content-Transfer-Encoding:
|
|
schema:
|
|
type: string
|
|
example: base64
|
|
content:
|
|
application/pkcs7-mime:
|
|
schema:
|
|
type: string
|
|
format: byte
|
|
description: "Base64-encoded PKCS#7 (smime-type=certs-only)"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"405":
|
|
description: Method not allowed (only POST accepted)
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/.well-known/est/csrattrs:
|
|
get:
|
|
tags: [EST]
|
|
summary: EST CSR attributes
|
|
description: |
|
|
Returns attributes the EST client should include in its CSR per
|
|
RFC 7030 §4.5. certctl currently returns an empty attribute set
|
|
(HTTP 204) — profile-based constraints are enforced server-side
|
|
during enrollment rather than advertised here.
|
|
operationId: estCSRAttrs
|
|
security: []
|
|
responses:
|
|
"200":
|
|
description: Base64-encoded CsrAttrs (when non-empty)
|
|
headers:
|
|
Content-Transfer-Encoding:
|
|
schema:
|
|
type: string
|
|
example: base64
|
|
content:
|
|
application/csrattrs:
|
|
schema:
|
|
type: string
|
|
format: byte
|
|
"204":
|
|
description: No CSR attributes defined (empty response)
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
/.well-known/est/serverkeygen:
|
|
post:
|
|
tags: [EST]
|
|
summary: EST server-driven key generation (RFC 7030 §4.4)
|
|
description: |
|
|
EST RFC 7030 §4.4 server-keygen endpoint. Server generates the
|
|
keypair, issues the certificate with the new pubkey, and returns
|
|
BOTH the cert (as `application/pkcs7-mime; smime-type=certs-only`)
|
|
AND the corresponding private key (as `application/pkcs7-mime;
|
|
smime-type=enveloped-data` — the private key is wrapped in CMS
|
|
EnvelopedData encrypted to the client's CSR-supplied
|
|
key-encipherment public key per RFC 7030 §4.4.2).
|
|
|
|
The two parts are returned as a `multipart/mixed` response body
|
|
with a per-response random boundary. Standard EST clients
|
|
(libest, openssl + smime) parse this multipart body natively.
|
|
|
|
Per-profile gate: this endpoint is registered for every EST
|
|
profile but returns 404 unless the operator opted in via
|
|
`CERTCTL_EST_PROFILE_<NAME>_SERVER_KEYGEN_ENABLED=true`. The
|
|
per-profile gate constrains the attack surface — server-driven
|
|
keygen requires the server to hold plaintext private keys
|
|
briefly, a meaningful trust delta from device-driven keygen.
|
|
|
|
Auth modes match the simpleenroll endpoint: HTTP Basic when the
|
|
per-profile enrollment-password is set, anonymous otherwise.
|
|
The mTLS sibling route at /.well-known/est-mtls/<PathID>/serverkeygen
|
|
is registered when the profile has MTLS_ENABLED=true.
|
|
|
|
EST RFC 7030 hardening master bundle Phase 5.
|
|
operationId: estServerKeygen
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
description: Base64-encoded PKCS#10 CSR. The CSR's Subject + SANs
|
|
drive the issued cert's identity. The CSR's pubkey MUST be RSA
|
|
— that pubkey is the encryption target for the returned
|
|
private key (CMS EnvelopedData uses RSA PKCS#1 v1.5 keyTrans).
|
|
content:
|
|
application/pkcs10:
|
|
schema:
|
|
type: string
|
|
format: byte
|
|
responses:
|
|
"200":
|
|
description: Multipart body with cert + EnvelopedData-wrapped key
|
|
content:
|
|
multipart/mixed:
|
|
schema:
|
|
type: string
|
|
format: byte
|
|
"400":
|
|
description: |
|
|
CSR malformed, CSR pubkey not RSA (RFC 7030 §4.4.2 requires
|
|
an encryption mechanism), or unsupported keygen algorithm
|
|
requested by the profile.
|
|
"401":
|
|
description: HTTP Basic auth failed (when enrollment-password is set)
|
|
"404":
|
|
description: Server-keygen not enabled for this profile
|
|
"429":
|
|
description: Per-(CN, source-IP) rate limit exceeded
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ─── SCEP (RFC 8894) ──────────────────────────────────────────────
|
|
/scep:
|
|
get:
|
|
tags: [SCEP]
|
|
summary: SCEP operation dispatch (GET)
|
|
description: |
|
|
Single SCEP entry point dispatched by the `operation` query parameter
|
|
per RFC 8894. GET is used for capability discovery (`GetCACaps`) and
|
|
CA certificate retrieval (`GetCACert`).
|
|
operationId: scepGet
|
|
security: []
|
|
parameters:
|
|
- name: operation
|
|
in: query
|
|
required: true
|
|
schema:
|
|
type: string
|
|
enum: [GetCACaps, GetCACert, PKIOperation]
|
|
description: SCEP operation selector
|
|
- name: message
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
description: Optional SCEP message parameter (base64-encoded for GET PKIOperation)
|
|
responses:
|
|
"200":
|
|
description: |
|
|
Success. Content-Type varies by operation:
|
|
- `GetCACaps` → `text/plain` capability list
|
|
- `GetCACert` (single cert) → `application/x-x509-ca-cert` (raw DER)
|
|
- `GetCACert` (chain) → `application/x-x509-ca-ra-cert` (PKCS#7)
|
|
- `PKIOperation` → `application/x-pki-message` (PKCS#7 SignedData)
|
|
content:
|
|
text/plain:
|
|
schema:
|
|
type: string
|
|
description: "SCEP capabilities (GetCACaps only)"
|
|
application/x-x509-ca-cert:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
description: "CA certificate DER (GetCACert single)"
|
|
application/x-x509-ca-ra-cert:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
description: "CA chain PKCS#7 (GetCACert chain)"
|
|
application/x-pki-message:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
description: "PKCS#7 SignedData response (PKIOperation)"
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
post:
|
|
tags: [SCEP]
|
|
summary: SCEP PKIOperation (POST)
|
|
description: |
|
|
SCEP enrollment / renewal / revocation request per RFC 8894.
|
|
Request body is a PKCS#7 SignedData envelope wrapping the PKCS#10 CSR
|
|
or a degenerate raw CSR (fallback). The challenge password in the CSR
|
|
attributes is validated against `CERTCTL_SCEP_CHALLENGE_PASSWORD` when
|
|
configured.
|
|
operationId: scepPost
|
|
security: []
|
|
parameters:
|
|
- name: operation
|
|
in: query
|
|
required: true
|
|
schema:
|
|
type: string
|
|
enum: [PKIOperation]
|
|
requestBody:
|
|
required: true
|
|
description: PKCS#7 SignedData envelope wrapping a PKCS#10 CSR (or raw CSR as fallback)
|
|
content:
|
|
application/x-pki-message:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
responses:
|
|
"200":
|
|
description: PKCS#7 SignedData PKIMessage response
|
|
content:
|
|
application/x-pki-message:
|
|
schema:
|
|
type: string
|
|
format: binary
|
|
"400":
|
|
$ref: "#/components/responses/BadRequest"
|
|
"500":
|
|
$ref: "#/components/responses/InternalError"
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
components:
|
|
securitySchemes:
|
|
bearerAuth:
|
|
type: http
|
|
scheme: bearer
|
|
description: API key passed as Bearer token. Configure via CERTCTL_AUTH_SECRET.
|
|
# Auth Bundle 2 Phase 5 — session-cookie auth scheme. New
|
|
# session-authenticated endpoints declare
|
|
# `security: [{cookieAuth: []}, {bearerAuth: []}]` (either auth
|
|
# method works, OR semantics). Per Phase 5 spec, the
|
|
# `/auth/oidc/back-channel-logout` endpoint declares `security: []`
|
|
# because auth comes from the IdP-signed logout token in the body,
|
|
# not certctl-issued credentials.
|
|
cookieAuth:
|
|
type: apiKey
|
|
in: cookie
|
|
name: certctl_session
|
|
description: |
|
|
Session cookie minted by `POST /auth/oidc/callback` after a
|
|
successful OIDC handshake (Auth Bundle 2). Wire format
|
|
`v1.<session_id>.<signing_key_id>.<HMAC-SHA256>`; HMAC is
|
|
verified server-side against the active session signing key.
|
|
Cookie attributes: `Secure` `HttpOnly` `SameSite=Lax|Strict`
|
|
(configurable via `CERTCTL_SESSION_SAMESITE`) `Path=/`.
|
|
State-changing requests additionally require the
|
|
`X-CSRF-Token` header to match the SHA-256 hash on the
|
|
session row (validated by the session middleware in Phase 6).
|
|
|
|
parameters:
|
|
resourceId:
|
|
name: id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
description: Human-readable resource ID (e.g., mc-api-prod, t-platform)
|
|
page:
|
|
name: page
|
|
in: query
|
|
schema:
|
|
type: integer
|
|
default: 1
|
|
minimum: 1
|
|
per_page:
|
|
name: per_page
|
|
in: query
|
|
schema:
|
|
type: integer
|
|
default: 50
|
|
minimum: 1
|
|
maximum: 500
|
|
|
|
responses:
|
|
BadRequest:
|
|
description: Validation error
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorResponse"
|
|
NotFound:
|
|
description: Resource not found
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorResponse"
|
|
Conflict:
|
|
description: Resource conflict
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorResponse"
|
|
InternalError:
|
|
description: Internal server error
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: "#/components/schemas/ErrorResponse"
|
|
|
|
schemas:
|
|
# ─── Auth / RBAC (Bundle 1 Phase 4) ─────────────────────────────
|
|
AuthRole:
|
|
type: object
|
|
required: [id, tenant_id, name]
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: Role ID (`r-` prefix).
|
|
example: r-admin
|
|
tenant_id:
|
|
type: string
|
|
example: t-default
|
|
name:
|
|
type: string
|
|
example: admin
|
|
description:
|
|
type: string
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
AuthRolePermission:
|
|
type: object
|
|
required: [role_id, permission_id, scope_type]
|
|
properties:
|
|
role_id:
|
|
type: string
|
|
permission_id:
|
|
type: string
|
|
scope_type:
|
|
type: string
|
|
enum: [global, profile, issuer]
|
|
scope_id:
|
|
type: string
|
|
description: NULL/absent for global scope; profile/issuer ID otherwise.
|
|
|
|
# ─── Approvals ───────────────────────────────────────────────────
|
|
ApprovalRequest:
|
|
type: object
|
|
description: |
|
|
Rank 7 issuance approval-workflow primitive. One row per (CertificateID,
|
|
JobID) pair; the JobID points at the blocked Job whose Status is
|
|
AwaitingApproval. Lifecycle: pending → approved | rejected | expired.
|
|
Once terminal, the row is immutable; the audit_events table is the
|
|
durable record of who decided + why.
|
|
required:
|
|
- id
|
|
- certificate_id
|
|
- job_id
|
|
- profile_id
|
|
- requested_by
|
|
- state
|
|
- created_at
|
|
- updated_at
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: Approval request ID (ar-<slug>).
|
|
certificate_id:
|
|
type: string
|
|
job_id:
|
|
type: string
|
|
profile_id:
|
|
type: string
|
|
requested_by:
|
|
type: string
|
|
description: Actor that triggered the renewal.
|
|
state:
|
|
type: string
|
|
enum: [pending, approved, rejected, expired]
|
|
decided_by:
|
|
type: string
|
|
nullable: true
|
|
description: Approver identity; null while state=pending.
|
|
decided_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
decision_note:
|
|
type: string
|
|
nullable: true
|
|
metadata:
|
|
type: object
|
|
additionalProperties:
|
|
type: string
|
|
description: Free-form key/value (common_name, sans, issuer_id, severity_tier).
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Common ──────────────────────────────────────────────────────
|
|
ErrorResponse:
|
|
type: object
|
|
properties:
|
|
error:
|
|
type: string
|
|
request_id:
|
|
type: string
|
|
|
|
# ARCH-001-A closure (Sprint 5, 2026-05-16). Three operation
|
|
# responses (search `#/components/schemas/Error` in this file)
|
|
# reference a schema named "Error" — but only "ErrorResponse" was
|
|
# defined, so the orval codegen failed with
|
|
# MissingPointerError. Alias Error → ErrorResponse so the spec
|
|
# parses cleanly and the three offenders keep their stable
|
|
# response shape.
|
|
Error:
|
|
$ref: "#/components/schemas/ErrorResponse"
|
|
|
|
StatusResponse:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
|
|
PaginationEnvelope:
|
|
type: object
|
|
properties:
|
|
total:
|
|
type: integer
|
|
format: int64
|
|
page:
|
|
type: integer
|
|
per_page:
|
|
type: integer
|
|
|
|
# ─── Certificates ────────────────────────────────────────────────
|
|
CertificateStatus:
|
|
type: string
|
|
enum:
|
|
- Pending
|
|
- Active
|
|
- Expiring
|
|
- Expired
|
|
- RenewalInProgress
|
|
- Failed
|
|
- Revoked
|
|
- Archived
|
|
|
|
ManagedCertificate:
|
|
# D-5 (cat-f-ae0d06b6588f, master): per-issuance fields
|
|
# (serial_number, fingerprint_sha256, key_algorithm, key_size,
|
|
# issued_at) are intentionally NOT declared here. They live on
|
|
# CertificateVersion (per-issuance evidence) and are fetched via
|
|
# /api/v1/certificates/{id}/versions. ManagedCertificate is the
|
|
# management envelope; CertificateVersion is the issuance record.
|
|
# Pre-D-5 the TS Certificate interface had them as optional and
|
|
# the dashboard's Key Algorithm / Key Size rows always rendered
|
|
# '—' as a result. The TS trim restores parity with this schema.
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
common_name:
|
|
type: string
|
|
sans:
|
|
type: array
|
|
items:
|
|
type: string
|
|
environment:
|
|
type: string
|
|
owner_id:
|
|
type: string
|
|
team_id:
|
|
type: string
|
|
issuer_id:
|
|
type: string
|
|
target_ids:
|
|
type: array
|
|
items:
|
|
type: string
|
|
renewal_policy_id:
|
|
type: string
|
|
certificate_profile_id:
|
|
type: string
|
|
status:
|
|
$ref: "#/components/schemas/CertificateStatus"
|
|
expires_at:
|
|
type: string
|
|
format: date-time
|
|
tags:
|
|
type: object
|
|
additionalProperties:
|
|
type: string
|
|
last_renewal_at:
|
|
type: string
|
|
format: date-time
|
|
last_deployment_at:
|
|
type: string
|
|
format: date-time
|
|
revoked_at:
|
|
type: string
|
|
format: date-time
|
|
revocation_reason:
|
|
type: string
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
required:
|
|
- name
|
|
- common_name
|
|
- renewal_policy_id
|
|
- issuer_id
|
|
- owner_id
|
|
- team_id
|
|
|
|
CertificateVersion:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
certificate_id:
|
|
type: string
|
|
serial_number:
|
|
type: string
|
|
not_before:
|
|
type: string
|
|
format: date-time
|
|
not_after:
|
|
type: string
|
|
format: date-time
|
|
fingerprint_sha256:
|
|
type: string
|
|
pem_chain:
|
|
type: string
|
|
csr_pem:
|
|
type: string
|
|
key_algorithm:
|
|
type: string
|
|
key_size:
|
|
type: integer
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
RevocationReason:
|
|
type: string
|
|
enum:
|
|
- unspecified
|
|
- keyCompromise
|
|
- caCompromise
|
|
- affiliationChanged
|
|
- superseded
|
|
- cessationOfOperation
|
|
- certificateHold
|
|
- privilegeWithdrawn
|
|
|
|
BulkRevokeRequest:
|
|
type: object
|
|
required: [reason]
|
|
properties:
|
|
reason:
|
|
$ref: "#/components/schemas/RevocationReason"
|
|
profile_id:
|
|
type: string
|
|
description: Revoke all certificates matching this profile
|
|
owner_id:
|
|
type: string
|
|
description: Revoke all certificates owned by this owner
|
|
agent_id:
|
|
type: string
|
|
description: Revoke all certificates deployed via this agent
|
|
issuer_id:
|
|
type: string
|
|
description: Revoke all certificates issued by this issuer
|
|
team_id:
|
|
type: string
|
|
description: Revoke all certificates owned by members of this team
|
|
certificate_ids:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Explicit list of certificate IDs to revoke
|
|
|
|
BulkRevokeResult:
|
|
type: object
|
|
properties:
|
|
total_matched:
|
|
type: integer
|
|
description: Number of certificates matching the criteria
|
|
total_revoked:
|
|
type: integer
|
|
description: Number of certificates successfully revoked
|
|
total_skipped:
|
|
type: integer
|
|
description: Number of certificates skipped (already revoked or archived)
|
|
total_failed:
|
|
type: integer
|
|
description: Number of certificates that failed to revoke
|
|
errors:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
certificate_id:
|
|
type: string
|
|
error:
|
|
type: string
|
|
description: Per-certificate error details for failed revocations
|
|
|
|
# L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
|
|
# bulk-renew + bulk-reassign request/result schemas. Mirror
|
|
# BulkRevokeRequest/Result envelope shape so frontend bulk-result
|
|
# rendering is one helper. See internal/domain/bulk_renewal.go +
|
|
# internal/domain/bulk_reassignment.go for the Go-side source of
|
|
# truth.
|
|
BulkRenewRequest:
|
|
type: object
|
|
description: Criteria for bulk renewal. At least one selector required.
|
|
properties:
|
|
profile_id:
|
|
type: string
|
|
description: Renew all certificates matching this profile
|
|
owner_id:
|
|
type: string
|
|
description: Renew all certificates owned by this owner
|
|
agent_id:
|
|
type: string
|
|
description: Renew all certificates deployed via this agent
|
|
issuer_id:
|
|
type: string
|
|
description: Renew all certificates issued by this issuer
|
|
team_id:
|
|
type: string
|
|
description: Renew all certificates owned by members of this team
|
|
certificate_ids:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Explicit list of certificate IDs to renew
|
|
|
|
BulkEnqueuedJob:
|
|
type: object
|
|
properties:
|
|
certificate_id:
|
|
type: string
|
|
job_id:
|
|
type: string
|
|
description: ID of the renewal job created for this certificate
|
|
|
|
BulkRenewResult:
|
|
type: object
|
|
properties:
|
|
total_matched:
|
|
type: integer
|
|
description: Number of certificates matching the criteria
|
|
total_enqueued:
|
|
type: integer
|
|
description: Number of renewal jobs successfully created
|
|
total_skipped:
|
|
type: integer
|
|
description: Certs already RenewalInProgress / Revoked / Archived / Expired (silent no-op)
|
|
total_failed:
|
|
type: integer
|
|
description: Number of certificates whose enqueue path returned an error
|
|
enqueued_jobs:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/BulkEnqueuedJob"
|
|
description: Per-certificate {certificate_id, job_id} pairs for the successful enqueue path
|
|
errors:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
certificate_id:
|
|
type: string
|
|
error:
|
|
type: string
|
|
description: Per-certificate error details for the failure path
|
|
|
|
BulkReassignRequest:
|
|
type: object
|
|
required: [certificate_ids, owner_id]
|
|
properties:
|
|
certificate_ids:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Explicit list of certificate IDs to reassign
|
|
owner_id:
|
|
type: string
|
|
description: Required. New owner_id for every cert in certificate_ids.
|
|
team_id:
|
|
type: string
|
|
description: Optional. When non-empty, also updates team_id on every cert.
|
|
|
|
BulkReassignResult:
|
|
type: object
|
|
properties:
|
|
total_matched:
|
|
type: integer
|
|
total_reassigned:
|
|
type: integer
|
|
description: Number of certs whose owner_id (and optionally team_id) was actually mutated
|
|
total_skipped:
|
|
type: integer
|
|
description: Certs already owned by the target (silent no-op)
|
|
total_failed:
|
|
type: integer
|
|
errors:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
certificate_id:
|
|
type: string
|
|
error:
|
|
type: string
|
|
|
|
# ─── Issuers ─────────────────────────────────────────────────────
|
|
IssuerType:
|
|
type: string
|
|
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS, AWSACMPCA, Entrust, GlobalSign, EJBCA]
|
|
|
|
Issuer:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
type:
|
|
$ref: "#/components/schemas/IssuerType"
|
|
config:
|
|
type: object
|
|
description: Issuer-specific configuration (varies by type)
|
|
enabled:
|
|
type: boolean
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Targets ─────────────────────────────────────────────────────
|
|
TargetType:
|
|
type: string
|
|
enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5, SSH, WinCertStore, JavaKeystore, KubernetesSecrets]
|
|
|
|
DeploymentTarget:
|
|
type: object
|
|
required: [name, type, agent_id]
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
type:
|
|
$ref: "#/components/schemas/TargetType"
|
|
agent_id:
|
|
type: string
|
|
description: |
|
|
ID of the agent that manages this target. Required because
|
|
deployment_targets.agent_id is a NOT NULL foreign key to agents(id)
|
|
(migration 000001). Empty or nonexistent agent IDs are rejected
|
|
with HTTP 400 by the service layer (see C-002 in the coverage-gap
|
|
audit).
|
|
config:
|
|
type: object
|
|
description: Target-specific configuration (varies by type)
|
|
enabled:
|
|
type: boolean
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Agents ──────────────────────────────────────────────────────
|
|
AgentStatus:
|
|
type: string
|
|
enum: [Online, Offline, Degraded]
|
|
|
|
Agent:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
hostname:
|
|
type: string
|
|
status:
|
|
$ref: "#/components/schemas/AgentStatus"
|
|
last_heartbeat_at:
|
|
type: string
|
|
format: date-time
|
|
registered_at:
|
|
type: string
|
|
format: date-time
|
|
# G-2 (P1): the `api_key_hash` field was REMOVED from this
|
|
# schema after cat-s5-apikey_leak audit closure. The DB column
|
|
# still exists (migrations/000001_initial_schema.up.sql) and
|
|
# the server still populates the in-memory struct for the
|
|
# auth-lookup path (repository.AgentRepository::GetByAPIKey),
|
|
# but the JSON wire shape no longer carries it — see
|
|
# internal/domain/connector.go::Agent::APIKeyHash + MarshalJSON
|
|
# for the redaction enforcement and docs/architecture.md ER
|
|
# diagram for the database-vs-API distinction. Do NOT re-add
|
|
# the field here without first removing the JSON-shape redaction
|
|
# in the domain package; the CI guardrail at
|
|
# .github/workflows/ci.yml will block re-introduction either way.
|
|
os:
|
|
type: string
|
|
architecture:
|
|
type: string
|
|
ip_address:
|
|
type: string
|
|
version:
|
|
type: string
|
|
retired_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: |
|
|
I-004: soft-retirement timestamp. `null` (or field absent) means the
|
|
agent is active. A non-null value is the canonical "retired" state —
|
|
the operational `status` column is preserved at retirement time as
|
|
the last-seen value, but `retired_at` is the source of truth for
|
|
filtering agents out of active listings.
|
|
retired_reason:
|
|
type: string
|
|
nullable: true
|
|
description: |
|
|
I-004: human-readable reason captured at retirement time. Only set
|
|
when the agent was retired via `?force=true&reason=...` cascade; a
|
|
default soft-retire leaves this field null.
|
|
|
|
AgentDependencyCounts:
|
|
type: object
|
|
description: |
|
|
I-004: preflight counts of active downstream rows that would be
|
|
orphaned by retiring an agent. Returned in the 409
|
|
`blocked_by_dependencies` body so the operator UI can tell the user
|
|
which bucket is blocking the retire, and also in the 200 response
|
|
body on a successful `?force=true` cascade as a snapshot of what
|
|
was cascaded.
|
|
properties:
|
|
active_targets:
|
|
type: integer
|
|
description: Deployment targets with this agent assigned and retired_at IS NULL
|
|
active_certificates:
|
|
type: integer
|
|
description: Certificates currently deployed via one of this agent's active targets
|
|
pending_jobs:
|
|
type: integer
|
|
description: Jobs with agent_id=this in status Pending, AwaitingCSR, AwaitingApproval, or Running
|
|
|
|
RetireAgentResponse:
|
|
type: object
|
|
description: |
|
|
I-004: response body for a successful retire on DELETE /api/v1/agents/{id}.
|
|
Returned on both clean retires (cascade=false, zero counts) and
|
|
force-cascade retires (cascade=true, counts snapshot of the
|
|
pre-cascade dependency state). The 204 idempotent-retire path does
|
|
NOT emit this body — re-retiring an already-retired agent returns
|
|
an empty response.
|
|
properties:
|
|
retired_at:
|
|
type: string
|
|
format: date-time
|
|
already_retired:
|
|
type: boolean
|
|
description: |
|
|
Always false on the 200 response — the already-retired path
|
|
returns 204 No Content with no body. Surfaced in the schema
|
|
only so downstream consumers have a complete field map.
|
|
cascade:
|
|
type: boolean
|
|
description: True when the retire was invoked with ?force=true
|
|
counts:
|
|
$ref: "#/components/schemas/AgentDependencyCounts"
|
|
|
|
BlockedByDependenciesResponse:
|
|
type: object
|
|
description: |
|
|
I-004: 409 response body for a retire request blocked by active
|
|
downstream dependencies. Returned when `force=true` is not set and
|
|
any of the three counts is non-zero. The operator UI renders these
|
|
counts so the human can retire or reassign the blocking rows
|
|
before re-running the retire, or tick the force checkbox to cascade.
|
|
properties:
|
|
error:
|
|
type: string
|
|
example: blocked_by_dependencies
|
|
message:
|
|
type: string
|
|
counts:
|
|
$ref: "#/components/schemas/AgentDependencyCounts"
|
|
|
|
WorkItem:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
type:
|
|
$ref: "#/components/schemas/JobType"
|
|
certificate_id:
|
|
type: string
|
|
common_name:
|
|
type: string
|
|
sans:
|
|
type: array
|
|
items:
|
|
type: string
|
|
target_id:
|
|
type: string
|
|
target_type:
|
|
type: string
|
|
target_config:
|
|
type: object
|
|
status:
|
|
$ref: "#/components/schemas/JobStatus"
|
|
|
|
# ─── Jobs ────────────────────────────────────────────────────────
|
|
JobType:
|
|
type: string
|
|
enum: [Issuance, Renewal, Deployment, Validation]
|
|
|
|
JobStatus:
|
|
type: string
|
|
enum:
|
|
- Pending
|
|
- AwaitingCSR
|
|
- AwaitingApproval
|
|
- Running
|
|
- Completed
|
|
- Failed
|
|
- Cancelled
|
|
|
|
Job:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
type:
|
|
$ref: "#/components/schemas/JobType"
|
|
certificate_id:
|
|
type: string
|
|
target_id:
|
|
type: string
|
|
status:
|
|
$ref: "#/components/schemas/JobStatus"
|
|
attempts:
|
|
type: integer
|
|
max_attempts:
|
|
type: integer
|
|
last_error:
|
|
type: string
|
|
scheduled_at:
|
|
type: string
|
|
format: date-time
|
|
started_at:
|
|
type: string
|
|
format: date-time
|
|
completed_at:
|
|
type: string
|
|
format: date-time
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Policies ────────────────────────────────────────────────────
|
|
PolicyType:
|
|
type: string
|
|
enum:
|
|
- AllowedIssuers
|
|
- AllowedDomains
|
|
- RequiredMetadata
|
|
- AllowedEnvironments
|
|
- RenewalLeadTime
|
|
- CertificateLifetime
|
|
|
|
PolicySeverity:
|
|
type: string
|
|
enum: [Warning, Error, Critical]
|
|
|
|
PolicyRule:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
type:
|
|
$ref: "#/components/schemas/PolicyType"
|
|
config:
|
|
type: object
|
|
description: Policy-specific configuration (varies by type)
|
|
enabled:
|
|
type: boolean
|
|
severity:
|
|
$ref: "#/components/schemas/PolicySeverity"
|
|
description: Severity level applied to violations of this rule. Defaults to Warning on create when omitted.
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
PolicyViolation:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
certificate_id:
|
|
type: string
|
|
rule_id:
|
|
type: string
|
|
message:
|
|
type: string
|
|
severity:
|
|
$ref: "#/components/schemas/PolicySeverity"
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Renewal Policies ─────────────────────────────────────────────
|
|
# G-1: renewal_policies table — lifecycle policies, referenced by
|
|
# managed_certificates.renewal_policy_id ON DELETE RESTRICT. Distinct
|
|
# from PolicyRule above (compliance rules, table policy_rules).
|
|
RenewalPolicy:
|
|
type: object
|
|
required:
|
|
- id
|
|
- name
|
|
- renewal_window_days
|
|
- auto_renew
|
|
- max_retries
|
|
- retry_interval_seconds
|
|
- alert_thresholds_days
|
|
- created_at
|
|
- updated_at
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: Human-readable ID, prefixed `rp-` (e.g., `rp-default`).
|
|
name:
|
|
type: string
|
|
description: Unique display name (UNIQUE in DB).
|
|
renewal_window_days:
|
|
type: integer
|
|
minimum: 1
|
|
maximum: 365
|
|
description: Days before expiry to trigger renewal.
|
|
auto_renew:
|
|
type: boolean
|
|
description: Whether renewal is triggered automatically by the scheduler.
|
|
max_retries:
|
|
type: integer
|
|
minimum: 0
|
|
maximum: 10
|
|
description: Maximum renewal retry attempts on failure.
|
|
retry_interval_seconds:
|
|
type: integer
|
|
minimum: 60
|
|
maximum: 86400
|
|
description: Seconds to wait between retry attempts.
|
|
alert_thresholds_days:
|
|
type: array
|
|
items:
|
|
type: integer
|
|
minimum: 0
|
|
maximum: 365
|
|
description: Days-before-expiry thresholds at which to emit alerts.
|
|
certificate_profile_id:
|
|
type: string
|
|
nullable: true
|
|
description: Optional certificate profile binding. Read-only at this endpoint; UI does not currently edit this field.
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
RenewalPolicyCreateRequest:
|
|
type: object
|
|
required:
|
|
- name
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: Optional human-readable ID. Auto-generated from name when omitted.
|
|
name:
|
|
type: string
|
|
minLength: 1
|
|
maxLength: 255
|
|
renewal_window_days:
|
|
type: integer
|
|
minimum: 1
|
|
maximum: 365
|
|
default: 30
|
|
auto_renew:
|
|
type: boolean
|
|
default: true
|
|
max_retries:
|
|
type: integer
|
|
minimum: 0
|
|
maximum: 10
|
|
description: Required. Not defaulted — 0 is a valid operator choice.
|
|
retry_interval_seconds:
|
|
type: integer
|
|
minimum: 60
|
|
maximum: 86400
|
|
default: 3600
|
|
alert_thresholds_days:
|
|
type: array
|
|
items:
|
|
type: integer
|
|
minimum: 0
|
|
maximum: 365
|
|
default: [30, 14, 7, 0]
|
|
|
|
RenewalPolicyUpdateRequest:
|
|
type: object
|
|
description: Partial update. Omitted fields are left unchanged.
|
|
properties:
|
|
name:
|
|
type: string
|
|
minLength: 1
|
|
maxLength: 255
|
|
renewal_window_days:
|
|
type: integer
|
|
minimum: 1
|
|
maximum: 365
|
|
auto_renew:
|
|
type: boolean
|
|
max_retries:
|
|
type: integer
|
|
minimum: 0
|
|
maximum: 10
|
|
retry_interval_seconds:
|
|
type: integer
|
|
minimum: 60
|
|
maximum: 86400
|
|
alert_thresholds_days:
|
|
type: array
|
|
items:
|
|
type: integer
|
|
minimum: 0
|
|
maximum: 365
|
|
|
|
# ─── Profiles ────────────────────────────────────────────────────
|
|
CertificateProfile:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
description:
|
|
type: string
|
|
allowed_key_algorithms:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/KeyAlgorithmRule"
|
|
max_ttl_seconds:
|
|
type: integer
|
|
allowed_ekus:
|
|
type: array
|
|
description: Extended Key Usages to include in issued certificates
|
|
items:
|
|
type: string
|
|
enum:
|
|
- serverAuth
|
|
- clientAuth
|
|
- codeSigning
|
|
- emailProtection
|
|
- timeStamping
|
|
required_san_patterns:
|
|
type: array
|
|
items:
|
|
type: string
|
|
spiffe_uri_pattern:
|
|
type: string
|
|
allow_short_lived:
|
|
type: boolean
|
|
enabled:
|
|
type: boolean
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
KeyAlgorithmRule:
|
|
type: object
|
|
properties:
|
|
algorithm:
|
|
type: string
|
|
enum: [RSA, ECDSA, Ed25519]
|
|
min_size:
|
|
type: integer
|
|
|
|
# ─── Teams ───────────────────────────────────────────────────────
|
|
Team:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
description:
|
|
type: string
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Owners ──────────────────────────────────────────────────────
|
|
Owner:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
email:
|
|
type: string
|
|
team_id:
|
|
type: string
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Agent Groups ────────────────────────────────────────────────
|
|
AgentGroup:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
description:
|
|
type: string
|
|
match_os:
|
|
type: string
|
|
match_architecture:
|
|
type: string
|
|
match_ip_cidr:
|
|
type: string
|
|
match_version:
|
|
type: string
|
|
enabled:
|
|
type: boolean
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Audit ───────────────────────────────────────────────────────
|
|
ActorType:
|
|
type: string
|
|
enum: [User, System, Agent]
|
|
|
|
AuditEvent:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
actor:
|
|
type: string
|
|
actor_type:
|
|
$ref: "#/components/schemas/ActorType"
|
|
action:
|
|
type: string
|
|
resource_type:
|
|
type: string
|
|
resource_id:
|
|
type: string
|
|
details:
|
|
type: object
|
|
timestamp:
|
|
type: string
|
|
format: date-time
|
|
event_category:
|
|
type: string
|
|
enum: [cert_lifecycle, auth, config]
|
|
description: |
|
|
Bundle 1 Phase 8: classifies the event for auditor-role
|
|
filtering. Empty / absent on rows from pre-Phase-8
|
|
deployments (the migration backfills "cert_lifecycle").
|
|
|
|
# ─── Notifications ───────────────────────────────────────────────
|
|
NotificationType:
|
|
type: string
|
|
enum:
|
|
- ExpirationWarning
|
|
- RenewalSuccess
|
|
- RenewalFailure
|
|
- DeploymentSuccess
|
|
- DeploymentFailure
|
|
- PolicyViolation
|
|
- Revocation
|
|
|
|
NotificationChannel:
|
|
type: string
|
|
enum: [Email, Webhook, Slack]
|
|
|
|
NotificationEvent:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
type:
|
|
$ref: "#/components/schemas/NotificationType"
|
|
certificate_id:
|
|
type: string
|
|
channel:
|
|
$ref: "#/components/schemas/NotificationChannel"
|
|
recipient:
|
|
type: string
|
|
message:
|
|
type: string
|
|
sent_at:
|
|
type: string
|
|
format: date-time
|
|
status:
|
|
type: string
|
|
enum: [pending, sent, failed, dead, read]
|
|
description: |
|
|
Notification lifecycle status. I-005 adds `dead` for notifications
|
|
that exhausted their 5-attempt retry budget and were moved to the
|
|
dead-letter queue; operators triage these in the GUI's Dead letter
|
|
tab and use POST /notifications/{id}/requeue to resurrect them.
|
|
error:
|
|
type: string
|
|
retry_count:
|
|
type: integer
|
|
description: |
|
|
Number of delivery attempts made. I-005 retry-sweep field; caps
|
|
at max_attempts=5 before the notification transitions to `dead`.
|
|
next_retry_at:
|
|
type: string
|
|
format: date-time
|
|
description: |
|
|
When the next retry attempt is scheduled. I-005 retry-sweep field;
|
|
null for `sent`, `dead`, and `read` statuses. Backoff follows
|
|
`min(2^retry_count * 1m, 1h)`.
|
|
last_error:
|
|
type: string
|
|
description: |
|
|
Most recent transient delivery error (SMTP failure, webhook 5xx,
|
|
etc.). I-005 retry-sweep field; surfaced on the Dead letter tab
|
|
so operators can triage without chasing server logs.
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Stats & Metrics ─────────────────────────────────────────────
|
|
DashboardSummary:
|
|
type: object
|
|
properties:
|
|
total_certificates:
|
|
type: integer
|
|
format: int64
|
|
expiring_certificates:
|
|
type: integer
|
|
format: int64
|
|
expired_certificates:
|
|
type: integer
|
|
format: int64
|
|
revoked_certificates:
|
|
type: integer
|
|
format: int64
|
|
active_agents:
|
|
type: integer
|
|
format: int64
|
|
offline_agents:
|
|
type: integer
|
|
format: int64
|
|
total_agents:
|
|
type: integer
|
|
format: int64
|
|
pending_jobs:
|
|
type: integer
|
|
format: int64
|
|
failed_jobs:
|
|
type: integer
|
|
format: int64
|
|
complete_jobs:
|
|
type: integer
|
|
format: int64
|
|
completed_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
MetricsResponse:
|
|
type: object
|
|
properties:
|
|
gauge:
|
|
type: object
|
|
properties:
|
|
certificate_total:
|
|
type: integer
|
|
format: int64
|
|
certificate_active:
|
|
type: integer
|
|
format: int64
|
|
certificate_expiring_soon:
|
|
type: integer
|
|
format: int64
|
|
certificate_expired:
|
|
type: integer
|
|
format: int64
|
|
certificate_revoked:
|
|
type: integer
|
|
format: int64
|
|
agent_total:
|
|
type: integer
|
|
format: int64
|
|
agent_online:
|
|
type: integer
|
|
format: int64
|
|
job_pending:
|
|
type: integer
|
|
format: int64
|
|
counter:
|
|
type: object
|
|
properties:
|
|
job_completed_total:
|
|
type: integer
|
|
format: int64
|
|
job_failed_total:
|
|
type: integer
|
|
format: int64
|
|
uptime:
|
|
type: object
|
|
properties:
|
|
uptime_seconds:
|
|
type: integer
|
|
format: int64
|
|
server_started:
|
|
type: string
|
|
format: date-time
|
|
measured_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
# ─── Discovery (M18b) ────────────────────────────────────────────
|
|
DiscoveredCertificate:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
fingerprint_sha256:
|
|
type: string
|
|
common_name:
|
|
type: string
|
|
sans:
|
|
type: array
|
|
items:
|
|
type: string
|
|
serial_number:
|
|
type: string
|
|
issuer_dn:
|
|
type: string
|
|
subject_dn:
|
|
type: string
|
|
not_before:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
not_after:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
key_algorithm:
|
|
type: string
|
|
key_size:
|
|
type: integer
|
|
is_ca:
|
|
type: boolean
|
|
source_path:
|
|
type: string
|
|
source_format:
|
|
type: string
|
|
agent_id:
|
|
type: string
|
|
discovery_scan_id:
|
|
type: string
|
|
nullable: true
|
|
managed_certificate_id:
|
|
type: string
|
|
nullable: true
|
|
status:
|
|
type: string
|
|
enum: [Unmanaged, Managed, Dismissed]
|
|
first_seen_at:
|
|
type: string
|
|
format: date-time
|
|
last_seen_at:
|
|
type: string
|
|
format: date-time
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
DiscoveryScan:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
agent_id:
|
|
type: string
|
|
directories:
|
|
type: array
|
|
items:
|
|
type: string
|
|
certificates_found:
|
|
type: integer
|
|
certificates_new:
|
|
type: integer
|
|
errors_count:
|
|
type: integer
|
|
scan_duration_ms:
|
|
type: integer
|
|
started_at:
|
|
type: string
|
|
format: date-time
|
|
completed_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
|
|
DiscoveryReport:
|
|
type: object
|
|
required: [agent_id, directories, certificates]
|
|
properties:
|
|
agent_id:
|
|
type: string
|
|
directories:
|
|
type: array
|
|
items:
|
|
type: string
|
|
certificates:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
fingerprint_sha256:
|
|
type: string
|
|
common_name:
|
|
type: string
|
|
sans:
|
|
type: array
|
|
items:
|
|
type: string
|
|
serial_number:
|
|
type: string
|
|
issuer_dn:
|
|
type: string
|
|
subject_dn:
|
|
type: string
|
|
not_before:
|
|
type: string
|
|
not_after:
|
|
type: string
|
|
key_algorithm:
|
|
type: string
|
|
key_size:
|
|
type: integer
|
|
is_ca:
|
|
type: boolean
|
|
pem_data:
|
|
type: string
|
|
source_path:
|
|
type: string
|
|
source_format:
|
|
type: string
|
|
errors:
|
|
type: array
|
|
items:
|
|
type: string
|
|
scan_duration_ms:
|
|
type: integer
|
|
|
|
StatusMessageResponse:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
message:
|
|
type: string
|
|
|
|
# ─── Network Scan (M21) ──────────────────────────────────────────
|
|
NetworkScanTarget:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
name:
|
|
type: string
|
|
cidrs:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: CIDR ranges to scan (max /20 per CIDR)
|
|
ports:
|
|
type: array
|
|
items:
|
|
type: integer
|
|
description: TCP ports to probe for TLS
|
|
enabled:
|
|
type: boolean
|
|
scan_interval_hours:
|
|
type: integer
|
|
description: Hours between scheduled scans
|
|
timeout_ms:
|
|
type: integer
|
|
description: Per-connection timeout in milliseconds
|
|
last_scan_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
last_scan_duration_ms:
|
|
type: integer
|
|
nullable: true
|
|
last_scan_certs_found:
|
|
type: integer
|
|
nullable: true
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
NetworkScanTargetCreate:
|
|
type: object
|
|
required: [name, cidrs]
|
|
properties:
|
|
name:
|
|
type: string
|
|
cidrs:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: CIDR ranges (max /20 per CIDR, max 4096 IPs)
|
|
ports:
|
|
type: array
|
|
items:
|
|
type: integer
|
|
description: TCP ports to probe (default [443])
|
|
enabled:
|
|
type: boolean
|
|
default: true
|
|
scan_interval_hours:
|
|
type: integer
|
|
default: 6
|
|
timeout_ms:
|
|
type: integer
|
|
default: 5000
|
|
|
|
EndpointHealthCheck:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: Health check ID
|
|
endpoint:
|
|
type: string
|
|
description: "Target endpoint (host:port)"
|
|
example: "api.example.com:443"
|
|
certificate_id:
|
|
type: string
|
|
nullable: true
|
|
description: Associated managed certificate ID (if from deployment)
|
|
network_scan_target_id:
|
|
type: string
|
|
nullable: true
|
|
description: Associated network scan target ID (if auto-created)
|
|
expected_fingerprint:
|
|
type: string
|
|
nullable: true
|
|
description: Expected certificate SHA-256 fingerprint
|
|
status:
|
|
type: string
|
|
enum: [Healthy, Degraded, Down, CertMismatch]
|
|
description: Current health status
|
|
enabled:
|
|
type: boolean
|
|
check_interval_seconds:
|
|
type: integer
|
|
description: Frequency of TLS probes (seconds)
|
|
timeout_ms:
|
|
type: integer
|
|
description: TLS connection timeout (milliseconds)
|
|
consecutive_failures:
|
|
type: integer
|
|
description: Number of consecutive probe failures
|
|
last_checked_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: Timestamp of last probe
|
|
last_success_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: Timestamp of last successful probe
|
|
last_failure_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: Timestamp of last failed probe
|
|
last_transition_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: Timestamp of last status transition
|
|
failure_reason:
|
|
type: string
|
|
nullable: true
|
|
description: Reason for last failure
|
|
acknowledged:
|
|
type: boolean
|
|
description: Whether the current status has been acknowledged
|
|
acknowledged_by:
|
|
type: string
|
|
nullable: true
|
|
description: Operator name who acknowledged (if applicable)
|
|
acknowledged_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
HealthHistoryEntry:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
health_check_id:
|
|
type: string
|
|
status:
|
|
type: string
|
|
enum: [Healthy, Degraded, Down, CertMismatch]
|
|
response_time_ms:
|
|
type: integer
|
|
nullable: true
|
|
description: Time to connect and complete TLS handshake (milliseconds)
|
|
observed_fingerprint:
|
|
type: string
|
|
nullable: true
|
|
description: SHA-256 fingerprint of certificate observed on endpoint
|
|
tls_version:
|
|
type: string
|
|
nullable: true
|
|
description: TLS version (e.g., TLSv1.3)
|
|
cipher_suite:
|
|
type: string
|
|
nullable: true
|
|
description: Cipher suite used in TLS handshake
|
|
cert_subject:
|
|
type: string
|
|
nullable: true
|
|
description: Subject DN of observed certificate
|
|
cert_issuer:
|
|
type: string
|
|
nullable: true
|
|
description: Issuer DN of observed certificate
|
|
cert_not_before:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
cert_not_after:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
failure_reason:
|
|
type: string
|
|
nullable: true
|
|
description: Error message if probe failed
|
|
checked_at:
|
|
type: string
|
|
format: date-time
|
|
description: Timestamp of this probe
|
|
|
|
# ─── Verification (M25) ──────────────────────────────────────────
|
|
VerifyDeploymentRequest:
|
|
type: object
|
|
required: [target_id, expected_fingerprint, actual_fingerprint, verified]
|
|
properties:
|
|
target_id:
|
|
type: string
|
|
description: Deployment target the agent probed
|
|
expected_fingerprint:
|
|
type: string
|
|
description: SHA-256 fingerprint of the certificate that should be served (hex, lowercase)
|
|
actual_fingerprint:
|
|
type: string
|
|
description: SHA-256 fingerprint observed on the live TLS endpoint (hex, lowercase)
|
|
verified:
|
|
type: boolean
|
|
description: True when expected and actual fingerprints match
|
|
error:
|
|
type: string
|
|
nullable: true
|
|
description: Error message when probe failed or fingerprints differ
|
|
|
|
VerificationResult:
|
|
type: object
|
|
properties:
|
|
job_id:
|
|
type: string
|
|
target_id:
|
|
type: string
|
|
expected_fingerprint:
|
|
type: string
|
|
description: SHA-256 fingerprint (hex) of the certificate deployed by this job
|
|
actual_fingerprint:
|
|
type: string
|
|
description: SHA-256 fingerprint (hex) observed on the live TLS endpoint
|
|
verified:
|
|
type: boolean
|
|
verified_at:
|
|
type: string
|
|
format: date-time
|
|
error:
|
|
type: string
|
|
description: Error message when verification failed
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 13 Sprint 13.4 (ARCH-H1 batch 1) schemas. Field-by-field
|
|
# mirror of the projection types in
|
|
# internal/api/handler/auth_session_oidc_{sessions,crud}.go +
|
|
# internal/auth/oidc/test_discovery.go +
|
|
# internal/auth/oidc/service.go::JWKSStatusSnapshot.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
AuthSession:
|
|
type: object
|
|
description: Mirrors internal/api/handler/auth_session_oidc_sessions.go::sessionResponse.
|
|
required: [id, actor_id, actor_type, created_at, last_seen_at, idle_expires_at, absolute_expires_at, revoked]
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: Session identifier (UUID-shaped).
|
|
actor_id:
|
|
type: string
|
|
description: Owning actor (user, API key, etc.).
|
|
actor_type:
|
|
type: string
|
|
description: Actor type — `user`, `api_key`, or `actor-demo-anon` in demo mode.
|
|
ip_address:
|
|
type: string
|
|
description: Source IP at session create-time. Omitted when not recorded.
|
|
user_agent:
|
|
type: string
|
|
description: User-Agent header at session create-time. Omitted when not recorded.
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp the session was minted.
|
|
last_seen_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp the session most-recently validated a request.
|
|
idle_expires_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp past which the session is idle-expired (CERTCTL_SESSION_IDLE_TIMEOUT from last_seen_at).
|
|
absolute_expires_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp past which the session is absolute-expired regardless of activity (CERTCTL_SESSION_ABSOLUTE_TIMEOUT from created_at).
|
|
revoked:
|
|
type: boolean
|
|
description: True when the session has been revoked (via this API or via back-channel-logout).
|
|
|
|
OIDCProviderResponse:
|
|
type: object
|
|
description: Mirrors internal/api/handler/auth_session_oidc_crud.go::oidcProviderResponse.
|
|
required:
|
|
[id, tenant_id, name, issuer_url, client_id, redirect_uri,
|
|
groups_claim_path, groups_claim_format, fetch_userinfo,
|
|
iat_window_seconds, jwks_cache_ttl_seconds, created_at, updated_at]
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: Provider identifier (`op-` + base64-URL random suffix).
|
|
tenant_id:
|
|
type: string
|
|
description: Owning tenant.
|
|
name:
|
|
type: string
|
|
description: Operator-facing provider name (unique per tenant).
|
|
issuer_url:
|
|
type: string
|
|
description: Canonical OIDC issuer URL. Must match the `iss` claim on returned ID tokens.
|
|
client_id:
|
|
type: string
|
|
description: Client identifier registered with the IdP.
|
|
redirect_uri:
|
|
type: string
|
|
description: Absolute URL the IdP redirects to after authorization.
|
|
groups_claim_path:
|
|
type: string
|
|
description: JSONPath-style claim path that the group→role mapper reads (default `groups`).
|
|
groups_claim_format:
|
|
type: string
|
|
description: How the claim is shaped (default `string_array`).
|
|
fetch_userinfo:
|
|
type: boolean
|
|
description: Whether to call the IdP's userinfo endpoint after token exchange (extends the available claims surface).
|
|
scopes:
|
|
type: array
|
|
items: { type: string }
|
|
description: OAuth scopes requested at authorization (typically `openid`, `email`, `profile`, and optionally `groups`).
|
|
allowed_email_domains:
|
|
type: array
|
|
items: { type: string }
|
|
description: Whitelisted email-domain suffixes; empty means accept any email-domain.
|
|
iat_window_seconds:
|
|
type: integer
|
|
description: Maximum allowed iat-skew for received ID tokens (default 300).
|
|
jwks_cache_ttl_seconds:
|
|
type: integer
|
|
description: JWKS cache TTL before refetch (default 3600).
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
OIDCProviderRequest:
|
|
type: object
|
|
description: Mirrors internal/api/handler/auth_session_oidc_crud.go::oidcProviderRequest. `client_secret` is plaintext on the wire only at create/update time; encrypted at rest via `CERTCTL_CONFIG_ENCRYPTION_KEY`.
|
|
required: [name, issuer_url, client_id, client_secret, redirect_uri]
|
|
properties:
|
|
name:
|
|
type: string
|
|
issuer_url:
|
|
type: string
|
|
client_id:
|
|
type: string
|
|
client_secret:
|
|
type: string
|
|
format: password
|
|
description: IdP client secret. Encrypted at rest after submission; never echoed back on read endpoints.
|
|
redirect_uri:
|
|
type: string
|
|
groups_claim_path:
|
|
type: string
|
|
description: Optional; defaults to `groups` when blank.
|
|
groups_claim_format:
|
|
type: string
|
|
description: Optional; defaults to `string_array` when blank.
|
|
fetch_userinfo:
|
|
type: boolean
|
|
scopes:
|
|
type: array
|
|
items: { type: string }
|
|
allowed_email_domains:
|
|
type: array
|
|
items: { type: string }
|
|
iat_window_seconds:
|
|
type: integer
|
|
description: Optional; defaults to 300 when zero.
|
|
jwks_cache_ttl_seconds:
|
|
type: integer
|
|
description: Optional; defaults to 3600 when zero.
|
|
|
|
OIDCTestRequest:
|
|
type: object
|
|
description: Mirrors the anonymous struct inside auth_session_oidc_crud.go::TestProvider. Discovery-only dry-run; the IdP's discovery + JWKS are fetched and validated WITHOUT persisting anything.
|
|
required: [issuer_url]
|
|
properties:
|
|
issuer_url:
|
|
type: string
|
|
description: Candidate OIDC issuer URL to dry-run.
|
|
client_id:
|
|
type: string
|
|
description: Optional — only used to confirm the discovery doc advertises matching audience.
|
|
client_secret:
|
|
type: string
|
|
format: password
|
|
description: Optional — discovery + JWKS don't require it, but the GUI passes it through so the dry-run shape matches CreateProvider's surface.
|
|
scopes:
|
|
type: array
|
|
items: { type: string }
|
|
|
|
OIDCTestDiscoveryResult:
|
|
type: object
|
|
description: Mirrors internal/auth/oidc/test_discovery.go::TestDiscoveryResult. Each field is independently observable so the GUI can render a per-check status row. `errors` is non-empty for both total failures (200 with all checks false) and partial-success cases (200 with some checks true).
|
|
required: [discovery_succeeded, jwks_reachable, supported_alg_values, iss_param_supported, current_kids]
|
|
properties:
|
|
discovery_succeeded:
|
|
type: boolean
|
|
description: True when `<issuer>/.well-known/openid-configuration` fetched + parsed cleanly.
|
|
jwks_reachable:
|
|
type: boolean
|
|
description: True when the JWKS URI advertised by the discovery doc returned a JWKS document.
|
|
supported_alg_values:
|
|
type: array
|
|
items: { type: string }
|
|
description: ID-token signing algorithms the IdP advertises support for.
|
|
iss_param_supported:
|
|
type: boolean
|
|
description: True when the discovery doc advertises support for RFC 9207 `iss` parameter (cross-IdP mix-up defense).
|
|
issuer_echo:
|
|
type: string
|
|
description: The `iss` value the IdP's discovery doc advertises; surfaces IdP misconfigurations where this differs from the issuer URL the operator submitted.
|
|
authorization_url:
|
|
type: string
|
|
token_url:
|
|
type: string
|
|
jwks_uri:
|
|
type: string
|
|
userinfo_endpoint:
|
|
type: string
|
|
current_kids:
|
|
type: array
|
|
items: { type: string }
|
|
description: Current key-IDs reachable in the JWKS document at probe time. Always present in the response payload (empty array when no keys are reachable).
|
|
errors:
|
|
type: array
|
|
items: { type: string }
|
|
description: Per-leg failure messages; empty on full success, non-empty on partial-success and full-failure cases.
|
|
|
|
OIDCJWKSStatusSnapshot:
|
|
type: object
|
|
description: Mirrors internal/auth/oidc/service.go::JWKSStatusSnapshot. Per-provider JWKS verifier counters surfaced for operator diagnostics.
|
|
required: [current_kids, refresh_count, rejected_jws_count, iss_param_supported]
|
|
properties:
|
|
last_refresh_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp the JWKS was most-recently refreshed. Omitted before the first refresh.
|
|
current_kids:
|
|
type: array
|
|
items: { type: string }
|
|
description: Currently-cached JWKS key IDs.
|
|
refresh_count:
|
|
type: integer
|
|
description: Lifetime count of JWKS refresh fetches for this provider.
|
|
last_error:
|
|
type: string
|
|
description: Last refresh-error message; omitted when no refresh has failed.
|
|
rejected_jws_count:
|
|
type: integer
|
|
description: Lifetime count of JWS verifications rejected against this provider's JWKS (debugging hint for IdP key-rotation issues).
|
|
iss_param_supported:
|
|
type: boolean
|
|
description: Whether the provider's discovery doc advertised RFC 9207 `iss` parameter support at the most recent refresh.
|
|
|
|
OIDCGroupMappingResponse:
|
|
type: object
|
|
description: Mirrors internal/api/handler/auth_session_oidc_crud.go::groupMappingResponse.
|
|
required: [id, provider_id, group_name, role_id, tenant_id, created_at]
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: Mapping identifier (`grm-` + base64-URL random suffix).
|
|
provider_id:
|
|
type: string
|
|
description: Owning OIDC provider.
|
|
group_name:
|
|
type: string
|
|
description: Group name as advertised by the IdP's groups claim.
|
|
role_id:
|
|
type: string
|
|
description: Role granted to members of `group_name` for this provider/tenant.
|
|
tenant_id:
|
|
type: string
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
|
|
OIDCGroupMappingRequest:
|
|
type: object
|
|
description: Mirrors internal/api/handler/auth_session_oidc_crud.go::groupMappingRequest. Tenant is derived from the calling actor; not accepted from the request body.
|
|
required: [provider_id, group_name, role_id]
|
|
properties:
|
|
provider_id:
|
|
type: string
|
|
group_name:
|
|
type: string
|
|
role_id:
|
|
type: string
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 13 Sprint 13.5 (ARCH-H1 batch 2) schemas. Field-by-field
|
|
# mirror of the projection types in
|
|
# internal/api/handler/auth_breakglass.go + auth_users.go.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
BreakglassCredentialResponse:
|
|
type: object
|
|
description: |
|
|
Mirrors internal/api/handler/auth_breakglass.go::
|
|
breakglassCredentialResponse. Password hash is NEVER serialized
|
|
to the wire — only metadata.
|
|
required: [actor_id, created_at, last_password_change_at, failure_count]
|
|
properties:
|
|
actor_id:
|
|
type: string
|
|
description: Actor the credential belongs to.
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp the credential was first set.
|
|
last_password_change_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp the password was most-recently rotated.
|
|
failure_count:
|
|
type: integer
|
|
description: Current consecutive-failure counter (Argon2id lockout state-machine input).
|
|
locked_until:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: RFC 3339 UTC timestamp past which the lockout clears organically. Omitted when no active lockout.
|
|
last_failure_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: RFC 3339 UTC timestamp of the most recent failed-attempt. Omitted when failure_count == 0.
|
|
|
|
BreakglassCredentialListResponse:
|
|
type: object
|
|
description: |
|
|
Mirrors internal/api/handler/auth_breakglass.go::
|
|
listBreakglassCredentialsResponse.
|
|
required: [credentials]
|
|
properties:
|
|
credentials:
|
|
type: array
|
|
items:
|
|
$ref: "#/components/schemas/BreakglassCredentialResponse"
|
|
|
|
BreakglassSetPasswordRequest:
|
|
type: object
|
|
description: |
|
|
Mirrors internal/api/handler/auth_breakglass.go::
|
|
breakglassSetPasswordRequest. Password is plaintext on the wire
|
|
ONLY at set-time; stored at rest as an Argon2id hash with
|
|
per-record salt.
|
|
required: [actor_id, password]
|
|
properties:
|
|
actor_id:
|
|
type: string
|
|
description: Actor the password is being set for.
|
|
password:
|
|
type: string
|
|
format: password
|
|
description: New break-glass password. Validated server-side against the strength policy (min 12 bytes, max 256 bytes).
|
|
|
|
BreakglassSetPasswordResponse:
|
|
type: object
|
|
description: |
|
|
Mirrors the inline response body returned by
|
|
AuthBreakglassHandler.SetPassword: actor_id + the credential's
|
|
created_at timestamp (RFC 3339, UTC).
|
|
required: [actor_id, created_at]
|
|
properties:
|
|
actor_id:
|
|
type: string
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp the credential row was created (or re-created on rotation).
|
|
|
|
AuthUser:
|
|
type: object
|
|
description: |
|
|
Mirrors internal/api/handler/auth_users.go::userResponse. Federated
|
|
user shape (OIDC subject + provider). `deactivated_at` is the soft-
|
|
delete marker; nil/absent means the user is active.
|
|
required: [id, tenant_id, email, display_name, oidc_subject, oidc_provider_id, last_login_at, created_at]
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: User identifier (UUID-shaped).
|
|
tenant_id:
|
|
type: string
|
|
email:
|
|
type: string
|
|
description: Federated email claim from the IdP.
|
|
display_name:
|
|
type: string
|
|
description: Federated display name (preferred_username or name claim from the IdP).
|
|
oidc_subject:
|
|
type: string
|
|
description: The IdP's `sub` claim for this user (stable identifier across email changes).
|
|
oidc_provider_id:
|
|
type: string
|
|
description: ID of the OIDC provider that minted this user record.
|
|
last_login_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp of the user's most-recent successful login.
|
|
created_at:
|
|
type: string
|
|
format: date-time
|
|
description: RFC 3339 UTC timestamp the user row was first created (upserted from an OIDC callback).
|
|
deactivated_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: RFC 3339 UTC timestamp the user was deactivated. Omitted when the user is active.
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 13 Sprint 13.6 (ARCH-H1 batch 3 — final batch) schemas.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
DemoResidualCleanupResponse:
|
|
type: object
|
|
description: |
|
|
Mirrors internal/api/handler/demo_residual.go::
|
|
demoResidualCleanupResponse. Always present; idempotent re-runs
|
|
return `removed: 0`.
|
|
required: [removed]
|
|
properties:
|
|
removed:
|
|
type: integer
|
|
format: int64
|
|
description: Number of `actor_roles` rows removed in this cleanup call.
|
|
|
|
BreakglassLoginRequest:
|
|
type: object
|
|
description: |
|
|
Mirrors internal/api/handler/auth_breakglass.go::
|
|
breakglassLoginRequest. Plaintext password on the wire ONLY at
|
|
login-time; the service hashes via Argon2id for the
|
|
constant-time compare.
|
|
required: [actor_id, password]
|
|
properties:
|
|
actor_id:
|
|
type: string
|
|
description: Actor attempting recovery login.
|
|
password:
|
|
type: string
|
|
format: password
|
|
description: Plaintext password (Argon2id-hashed at rest by the service).
|