Files
certctl/api/openapi.yaml
T
shankar0123 38f1200f26 fix(api,codegen): ARCH-001-A — Phase 1 Orval codegen + 2 new CI guards (large diff)
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.
2026-05-16 05:19:22 +00:00

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).