Files
certctl/api/openapi.yaml
T
shankar0123 952682ebec docs(arch-h1): Phase 13 Sprint 13.4 — OpenAPI auth/sessions + OIDC ops (batch 1, 13 ops)
Phase 13 Sprint 13.4 closure (architecture diligence audit ARCH-H1):
authors OpenAPI operations for the auth/sessions cluster (3) +
auth/oidc CRUD + JWKS + test + refresh cluster (10), drives the
`rest-deferred` exception bucket from 28 → 15.

OpenAPI-only sprint: zero Go changes. Every schema field-by-field
mirrors the projection types in the Phase 9 Sprint 11 sibling-file
handlers (auth_session_oidc_{sessions,crud}.go) + the JWKS-status
surface in auth_users.go + the dry-run discovery result in
internal/auth/oidc/test_discovery.go.

13 new operations
=================

  Sessions cluster (3 ops):
    GET    /api/v1/auth/sessions                listAuthSessions
    DELETE /api/v1/auth/sessions                revokeAuthSessionsExceptCurrent
    DELETE /api/v1/auth/sessions/{id}           revokeAuthSession

  OIDC provider CRUD + JWKS + test + refresh (7 ops):
    GET    /api/v1/auth/oidc/providers                  listOIDCProviders
    POST   /api/v1/auth/oidc/providers                  createOIDCProvider
    PUT    /api/v1/auth/oidc/providers/{id}             updateOIDCProvider
    DELETE /api/v1/auth/oidc/providers/{id}             deleteOIDCProvider
    GET    /api/v1/auth/oidc/providers/{id}/jwks-status getOIDCProviderJWKSStatus
    POST   /api/v1/auth/oidc/providers/{id}/refresh     refreshOIDCProvider
    POST   /api/v1/auth/oidc/test                       testOIDCProvider

  OIDC group-mapping CRUD (3 ops):
    GET    /api/v1/auth/oidc/group-mappings             listOIDCGroupMappings
    POST   /api/v1/auth/oidc/group-mappings             addOIDCGroupMapping
    DELETE /api/v1/auth/oidc/group-mappings/{id}        removeOIDCGroupMapping

8 new schemas (components/schemas)
==================================

  AuthSession                — mirrors sessionResponse (10 fields).
  OIDCProviderResponse       — mirrors oidcProviderResponse (15 fields).
  OIDCProviderRequest        — mirrors oidcProviderRequest (12 fields,
                               client_secret marked password).
  OIDCTestRequest            — mirrors the inline struct in TestProvider
                               (4 fields).
  OIDCTestDiscoveryResult    — mirrors oidc.TestDiscoveryResult
                               (11 fields).
  OIDCJWKSStatusSnapshot     — mirrors oidc.JWKSStatusSnapshot (7
                               fields).
  OIDCGroupMappingResponse   — mirrors groupMappingResponse (6 fields).
  OIDCGroupMappingRequest    — mirrors groupMappingRequest (3 fields,
                               tenant_id deliberately excluded — derived
                               from caller).

Every schema field's JSON tag, type, required-ness, and (where
applicable) description grounded against the Go source byte-for-byte.
Pointer types in Go that the handler marshals via `omitempty` are
modelled as optional fields in the YAML (not present in the
`required` list).

RBAC permissions documented per-operation in the description (matched
against rbacGate wraps in internal/api/router/router.go lines 516-540):
  auth.session.list, auth.session.list.all, auth.session.revoke,
  auth.oidc.list, auth.oidc.create, auth.oidc.edit, auth.oidc.delete.

New tags
========

Added `Sessions` and `OIDC` to the `tags:` list with cross-references
to the handler file paths. Existing operations stay on existing tags;
the new ones declare the new tags.

Exception YAML + baseline
=========================

13 entries removed from api/openapi-handler-exceptions.yaml. The
post-cut shape:

  total entries:           51   (was 64)
  wire-protocol:           36   (unchanged — never burn down)
  rest-deferred:           15   (was 28)

Baseline file bumped 28 → 15. The Sprint 13.1 monotonic-decrease
guard now pins `rest-deferred ≤ 15`. Sprints 13.5 + 13.6 walk it down
to zero (15 → 7 → 0).

YAML header narrative updated to reflect Sprint 13.4 status:
"Sprint 13.4 SHIPPED — 28 - 13 = 15".

Receipts (all from the live tree)
=================================

  $ grep -cE '^\s+operationId:' api/openapi.yaml
    171   (was 158 + 13)

  $ bash scripts/ci-guards/openapi-handler-parity.sh
    Router routes:                  220
    OpenAPI operations:             171
    Documented exceptions:          51
      wire-protocol:                36
      rest-deferred:                15
    openapi-handler-parity: clean.

  $ bash scripts/ci-guards/openapi-rest-deferred-monotonic.sh
    openapi-rest-deferred-monotonic: clean — rest-deferred = 15,
    baseline = 15.

  $ cat api/openapi-handler-exceptions-baseline.txt
    15

  $ python3 -c "import yaml; spec=yaml.safe_load(open('api/openapi.yaml')); ..."
    paths: 126, operations: 171
    components.schemas: 67
    sprint-13.4 schemas missing: (none)
    OpenAPI lint: clean.

  $ gofmt -l .                  → clean
  $ go vet ./internal/api/handler/... ./cmd/server/...  → clean

Sprint 13.5 next (auth/breakglass + auth/users + auth/runtime-config,
8 ops; rest-deferred 15 → 7). Same OpenAPI-only authoring pattern; no
Go changes.

Refs: ARCH-H1 batch 1 closure.
2026-05-14 12:14:13 +00:00

7064 lines
236 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`.
version: 2.0.0
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
- 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.
description: Simple Certificate Enrollment Protocol (RFC 8894)
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 }
/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.
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)
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
"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
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