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