openapi: 3.1.0 info: title: certctl API description: | Certificate lifecycle management platform API. Manages certificates, issuers, deployment targets, agents, jobs, policies, profiles, teams, owners, agent groups, audit events, notifications, and observability metrics. All endpoints under `/api/v1/` require authentication by default (configurable via `CERTCTL_AUTH_TYPE`). Use `Bearer {api_key}` in the Authorization header. Paginated list endpoints accept `page` (default 1) and `per_page` (default 50, max 500) query parameters and return a standard envelope with `data`, `total`, `page`, and `per_page`. version: 2.0.0 license: name: BSL 1.1 url: https://github.com/certctl-io/certctl/blob/master/LICENSE servers: - url: https://localhost:8443 description: Docker Compose demo (self-signed cert; pin with ./deploy/test/certs/ca.crt) security: - bearerAuth: [] tags: - name: Certificates description: Certificate lifecycle — CRUD, versions, renewal, deployment, revocation - name: CRL & OCSP description: | Certificate revocation list (RFC 5280) and OCSP responder (RFC 6960). Served unauthenticated under `/.well-known/pki/*` (RFC 8615) so relying parties can retrieve revocation status without a certctl API key. - name: Issuers description: CA issuer connector management (Local CA, ACME, step-ca) - name: Targets description: Deployment target management (NGINX, Apache, HAProxy, F5, IIS) - name: Agents description: Agent registration, heartbeat, CSR submission, work polling - name: Jobs description: Job queue — issuance, renewal, deployment, validation - name: Policies description: Policy rules and violation tracking - name: RenewalPolicies description: Lifecycle renewal policies (distinct from compliance policy rules above) - name: Profiles description: Certificate enrollment profiles with crypto constraints - name: Teams description: Team management for ownership grouping - name: Owners description: Certificate owner management with email routing - name: Agent Groups description: Dynamic agent grouping by OS, architecture, IP CIDR, version - name: Audit description: Immutable audit trail - name: Notifications description: Notification events (expiration, renewal, deployment, revocation) - name: Stats description: Dashboard statistics and aggregations - name: Metrics description: System metrics (gauges, counters, uptime) - name: Health description: Health and readiness probes, auth info - name: Discovery description: Certificate discovery — filesystem scanning by agents and network TLS probing - name: Network Scan description: Network scan target management for active TLS certificate discovery - name: Health Monitoring description: Continuous TLS endpoint health checks with status tracking and probe history - name: Digest description: Scheduled certificate digest email notifications - name: Verification description: Post-deployment TLS endpoint fingerprint verification - name: EST description: Enrollment over Secure Transport (RFC 7030) - name: SCEP description: Simple Certificate Enrollment Protocol (RFC 8894) paths: # ─── Health & Auth ─────────────────────────────────────────────────── /health: get: tags: [Health] summary: Health check security: [] operationId: getHealth responses: "200": description: Server is healthy content: application/json: schema: type: object properties: status: type: string example: healthy /ready: get: tags: [Health] summary: Readiness check security: [] operationId: getReady responses: "200": description: Server is ready content: application/json: schema: type: object properties: status: type: string example: ready /api/v1/auth/info: get: tags: [Health] summary: Auth configuration info description: Returns auth mode. Served without auth so GUI can detect auth requirements before login. security: [] operationId: getAuthInfo responses: "200": description: Auth configuration content: application/json: schema: type: object properties: auth_type: type: string # G-1 (P1): "jwt" removed from this enum after the silent # auth downgrade was identified — no JWT middleware ships # with certctl. Operators who need JWT/OIDC 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". enum: [api-key, none] 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 } /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. operationId: listAuditEvents parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/per_page" - in: query name: category schema: type: string enum: [cert_lifecycle, auth, config] description: Filter to events of this event_category. (Bundle 1 Phase 8) responses: "200": description: Paginated list of audit events content: application/json: schema: allOf: - $ref: "#/components/schemas/PaginationEnvelope" - type: object properties: data: type: array items: $ref: "#/components/schemas/AuditEvent" "400": description: Invalid `category` value "500": $ref: "#/components/responses/InternalError" /api/v1/audit/{id}: get: tags: [Audit] summary: Get audit event operationId: getAuditEvent parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Audit event details content: application/json: schema: $ref: "#/components/schemas/AuditEvent" "400": $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" # ─── Notifications ────────────────────────────────────────────────── /api/v1/approvals: get: tags: [Approvals] summary: List approval requests description: | Rank 7 issuance approval-workflow primitive. Returns paginated approval requests, optionally filtered by ?state= (pending/approved/rejected/expired), ?certificate_id=, or ?requested_by=. Empty filters return the unfiltered list (default page=1, per_page=50). operationId: listApprovalRequests parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/per_page" - name: state in: query required: false schema: type: string enum: [pending, approved, rejected, expired] - name: certificate_id in: query required: false schema: type: string - name: requested_by in: query required: false schema: type: string responses: "200": description: Paginated list of approval requests content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/ApprovalRequest" page: type: integer per_page: type: integer "500": $ref: "#/components/responses/InternalError" /api/v1/approvals/{id}: get: tags: [Approvals] summary: Get approval request description: Returns a single approval request by ID. operationId: getApprovalRequest parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Approval request details content: application/json: schema: $ref: "#/components/schemas/ApprovalRequest" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/approvals/{id}/approve: post: tags: [Approvals] summary: Approve a pending approval request description: | Transitions a pending request to approved AND transitions the linked Job from AwaitingApproval to Pending so the scheduler picks it up. RBAC: the authenticated actor extracted via the auth middleware MUST differ from the request's requested_by — a same-actor self-approval returns HTTP 403 with the substring `two-person integrity` in the body. This is the load-bearing two-person integrity contract; compliance auditors (PCI-DSS 6.4.5, NIST 800-53 SA-15, SOC 2 CC6.1) pattern-match against this code path. operationId: approveApprovalRequest parameters: - $ref: "#/components/parameters/resourceId" requestBody: required: false content: application/json: schema: type: object properties: note: type: string description: Optional reason text for the audit trail. responses: "200": description: Approval recorded; linked Job transitioned to Pending content: application/json: schema: type: object properties: id: { type: string } decided_by: { type: string } action: { type: string, enum: [approved] } "401": description: Authentication required "403": description: Same-actor self-approval blocked by two-person integrity contract "404": $ref: "#/components/responses/NotFound" "409": description: Request already decided (terminal state) "500": $ref: "#/components/responses/InternalError" /api/v1/approvals/{id}/reject: post: tags: [Approvals] summary: Reject a pending approval request description: | Transitions a pending request to rejected AND cancels the linked Job. Same-actor RBAC contract as approve. The job's error_message is populated with the supplied note for audit continuity. operationId: rejectApprovalRequest parameters: - $ref: "#/components/parameters/resourceId" requestBody: required: false content: application/json: schema: type: object properties: note: type: string description: Optional reason text for the audit trail. responses: "200": description: Rejection recorded; linked Job transitioned to Cancelled content: application/json: schema: type: object properties: id: { type: string } decided_by: { type: string } action: { type: string, enum: [rejected] } "401": description: Authentication required "403": description: Same-actor self-rejection blocked by two-person integrity contract "404": $ref: "#/components/responses/NotFound" "409": description: Request already decided (terminal state) "500": $ref: "#/components/responses/InternalError" /api/v1/issuers/{id}/intermediates: post: tags: [IntermediateCAs] summary: Create a root or child intermediate CA under the issuer description: | Admin-gated. Discriminator on body shape: when parent_ca_id is empty AND root_cert_pem + key_driver_id are present, the endpoint registers an operator-supplied root CA. Otherwise it signs a child sub-CA cert under the named parent (RFC 5280 §4.2.1.9 path-length tightening + §4.2.1.10 NameConstraints subset semantics enforced at the service layer). operationId: createIntermediateCA parameters: - $ref: "#/components/parameters/resourceId" requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } parent_ca_id: type: string description: Empty for root registration; non-empty for child signing root_cert_pem: type: string description: Operator-supplied root cert PEM (root path only) key_driver_id: type: string description: signer.Driver reference for the root key (root path only) subject: type: object description: Distinguished name for child CA (child path only) algorithm: type: string description: Signing algorithm for child key (default ECDSA-P256) ttl_days: type: integer path_len_constraint: type: integer nullable: true name_constraints: type: array items: { type: object } ocsp_responder_url: type: string metadata: type: object responses: "201": description: IntermediateCA row created "400": description: Validation failed (RFC 5280 violations, malformed cert PEM, missing root bundle) "401": description: Authentication required "403": description: Admin role required "409": description: Parent CA not in active state "404": description: Parent CA not found "500": $ref: "#/components/responses/InternalError" get: tags: [IntermediateCAs] summary: List the CA hierarchy for an issuer description: | Admin-gated. Returns the flat list of every IntermediateCA row for the issuer, ordered by created_at. The caller renders the tree from each row's parent_ca_id (nil = root). operationId: listIntermediateCAs parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Flat list of CA rows content: application/json: schema: type: object properties: data: type: array items: { type: object } "401": description: Authentication required "403": description: Admin role required /api/v1/intermediates/{id}: get: tags: [IntermediateCAs] summary: Get a single intermediate CA by ID operationId: getIntermediateCA parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: IntermediateCA row "401": description: Authentication required "403": description: Admin role required "404": $ref: "#/components/responses/NotFound" /api/v1/intermediates/{id}/retire: post: tags: [IntermediateCAs] summary: Retire an intermediate CA (two-phase drain) description: | Admin-gated. Two-phase: first call (confirm=false) transitions active to retiring (the CA stops issuing new children but existing children continue). Second call (confirm=true) transitions retiring to retired (terminal). Refuses the terminal transition if the CA still has active children — drain-first semantics. operationId: retireIntermediateCA parameters: - $ref: "#/components/parameters/resourceId" requestBody: required: false content: application/json: schema: type: object properties: note: { type: string } confirm: { type: boolean, default: false } responses: "200": description: Retire transition recorded "401": description: Authentication required "403": description: Admin role required "404": $ref: "#/components/responses/NotFound" "409": description: CA still has active children; drain them first "500": $ref: "#/components/responses/InternalError" /api/v1/notifications: get: tags: [Notifications] summary: List notifications operationId: listNotifications parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/per_page" - name: status in: query required: false description: | Filter by lifecycle status. I-005: `dead` powers the Dead letter tab on the GUI; empty/omitted returns the default all-statuses listing to preserve pre-I-005 behavior. schema: type: string enum: [pending, sent, failed, dead, read] responses: "200": description: Paginated list of notifications content: application/json: schema: allOf: - $ref: "#/components/schemas/PaginationEnvelope" - type: object properties: data: type: array items: $ref: "#/components/schemas/NotificationEvent" "500": $ref: "#/components/responses/InternalError" /api/v1/notifications/{id}: get: tags: [Notifications] summary: Get notification operationId: getNotification parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Notification details content: application/json: schema: $ref: "#/components/schemas/NotificationEvent" "400": $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/notifications/{id}/read: post: tags: [Notifications] summary: Mark notification as read operationId: markNotificationAsRead parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Marked as read content: application/json: schema: $ref: "#/components/schemas/StatusResponse" "400": $ref: "#/components/responses/BadRequest" "500": $ref: "#/components/responses/InternalError" /api/v1/notifications/{id}/requeue: post: tags: [Notifications] summary: Requeue a dead notification description: | I-005: flip a notification from the `dead` dead-letter queue back to `pending` so the retry sweep (default 2 minutes) picks it up on its next tick. Used by operators after fixing the underlying delivery failure (SMTP config, webhook endpoint, etc.). Clears `next_retry_at` and resets the `retry_count` budget; `last_error` is preserved for audit continuity. operationId: requeueNotification parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Requeued content: application/json: schema: $ref: "#/components/schemas/StatusResponse" "400": $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "405": description: Method not allowed (POST only) "500": $ref: "#/components/responses/InternalError" # ─── Stats ─────────────────────────────────────────────────────────── /api/v1/stats/summary: get: tags: [Stats] summary: Dashboard summary operationId: getDashboardSummary responses: "200": description: High-level system metrics content: application/json: schema: $ref: "#/components/schemas/DashboardSummary" "500": $ref: "#/components/responses/InternalError" /api/v1/stats/certificates-by-status: get: tags: [Stats] summary: Certificate status breakdown operationId: getCertificatesByStatus responses: "200": description: Certificate counts by status content: application/json: schema: type: object properties: status_counts: type: array items: type: object properties: status: type: string count: type: integer format: int64 "500": $ref: "#/components/responses/InternalError" /api/v1/stats/expiration-timeline: get: tags: [Stats] summary: Expiration timeline operationId: getExpirationTimeline parameters: - name: days in: query schema: type: integer default: 30 minimum: 1 maximum: 365 responses: "200": description: Certificates expiring per day content: application/json: schema: type: object properties: buckets: type: array items: type: object properties: date: type: string format: date count: type: integer format: int64 "500": $ref: "#/components/responses/InternalError" /api/v1/stats/job-trends: get: tags: [Stats] summary: Job success/failure trends operationId: getJobTrends parameters: - name: days in: query schema: type: integer default: 30 minimum: 1 maximum: 365 responses: "200": description: Job trends per day content: application/json: schema: type: object properties: trends: type: array items: type: object properties: date: type: string format: date completed: type: integer format: int64 failed: type: integer format: int64 "500": $ref: "#/components/responses/InternalError" /api/v1/stats/issuance-rate: get: tags: [Stats] summary: Certificate issuance rate operationId: getIssuanceRate parameters: - name: days in: query schema: type: integer default: 30 minimum: 1 maximum: 365 responses: "200": description: Issuance count per day content: application/json: schema: type: object properties: rate: type: array items: type: object properties: date: type: string format: date count: type: integer format: int64 "500": $ref: "#/components/responses/InternalError" # ─── Metrics ───────────────────────────────────────────────────────── /api/v1/metrics: get: tags: [Metrics] summary: System metrics description: JSON metrics snapshot with gauges, counters, and uptime. See also /api/v1/metrics/prometheus for Prometheus exposition format. operationId: getMetrics responses: "200": description: Metrics snapshot content: application/json: schema: $ref: "#/components/schemas/MetricsResponse" "500": $ref: "#/components/responses/InternalError" # ─── Prometheus Metrics (M22) ────────────────────────────────────── /api/v1/metrics/prometheus: get: tags: [Metrics] summary: Prometheus metrics description: | Prometheus exposition format metrics. Compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics, and any OpenMetrics scraper. Returns 11 metrics with certctl_ prefix (8 gauges, 2 counters, 1 info). operationId: getPrometheusMetrics responses: "200": description: Prometheus text format content: text/plain: schema: type: string description: "Prometheus exposition format (text/plain; version=0.0.4)" "500": $ref: "#/components/responses/InternalError" # ─── Certificate Deployments (M20) ───────────────────────────────── /api/v1/certificates/{id}/deployments: get: tags: [Certificates] summary: List certificate deployments description: Returns deployment targets associated with this certificate. operationId: getCertificateDeployments parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Deployment targets for this certificate content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/DeploymentTarget" total: type: integer "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" # ─── Discovery (M18b) ───────────────────────────────────────────── /api/v1/agents/{id}/discoveries: post: tags: [Discovery] summary: Submit discovery report description: | Agent submits a batch of discovered certificates from filesystem scanning. Server deduplicates by (fingerprint, agent_id, source_path) and records scan metadata. operationId: submitDiscoveryReport parameters: - $ref: "#/components/parameters/resourceId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DiscoveryReport" responses: "202": description: Report accepted and processed content: application/json: schema: $ref: "#/components/schemas/DiscoveryScan" "400": $ref: "#/components/responses/BadRequest" "500": $ref: "#/components/responses/InternalError" /api/v1/discovered-certificates: get: tags: [Discovery] summary: List discovered certificates description: Returns discovered certificates with optional filters by agent and triage status. operationId: listDiscoveredCertificates parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/per_page" - name: agent_id in: query schema: type: string description: Filter by discovering agent - name: status in: query schema: type: string enum: [Unmanaged, Managed, Dismissed] description: Filter by triage status responses: "200": description: Paginated list of discovered certificates content: application/json: schema: allOf: - $ref: "#/components/schemas/PaginationEnvelope" - type: object properties: data: type: array items: $ref: "#/components/schemas/DiscoveredCertificate" "500": $ref: "#/components/responses/InternalError" /api/v1/discovered-certificates/{id}: get: tags: [Discovery] summary: Get discovered certificate description: Returns a single discovered certificate by ID. operationId: getDiscoveredCertificate parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Discovered certificate details content: application/json: schema: $ref: "#/components/schemas/DiscoveredCertificate" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/discovered-certificates/{id}/claim: post: tags: [Discovery] summary: Claim discovered certificate description: Links a discovered certificate to an existing managed certificate. Changes status to Managed. operationId: claimDiscoveredCertificate parameters: - $ref: "#/components/parameters/resourceId" requestBody: required: true content: application/json: schema: type: object required: [managed_certificate_id] properties: managed_certificate_id: type: string description: ID of the managed certificate to link to responses: "200": description: Certificate claimed content: application/json: schema: $ref: "#/components/schemas/StatusMessageResponse" "400": $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/discovered-certificates/{id}/dismiss: post: tags: [Discovery] summary: Dismiss discovered certificate description: Marks a discovered certificate as dismissed (excluded from triage queue). operationId: dismissDiscoveredCertificate parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Certificate dismissed content: application/json: schema: $ref: "#/components/schemas/StatusMessageResponse" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/discovery-scans: get: tags: [Discovery] summary: List discovery scans description: Returns history of discovery scan executions with optional agent filter. operationId: listDiscoveryScans parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/per_page" - name: agent_id in: query schema: type: string description: Filter by agent ID responses: "200": description: Paginated list of discovery scans content: application/json: schema: allOf: - $ref: "#/components/schemas/PaginationEnvelope" - type: object properties: data: type: array items: $ref: "#/components/schemas/DiscoveryScan" "500": $ref: "#/components/responses/InternalError" /api/v1/discovery-summary: get: tags: [Discovery] summary: Discovery status summary description: Returns aggregate counts of discovered certificates by triage status. operationId: getDiscoverySummary responses: "200": description: Status counts content: application/json: schema: type: object properties: Unmanaged: type: integer Managed: type: integer Dismissed: type: integer "500": $ref: "#/components/responses/InternalError" # ─── Network Scan Targets (M21) ─────────────────────────────────── /api/v1/network-scan-targets: get: tags: [Network Scan] summary: List network scan targets description: Returns all configured network scan targets with CIDR ranges and ports. operationId: listNetworkScanTargets responses: "200": description: List of network scan targets content: application/json: schema: allOf: - $ref: "#/components/schemas/PaginationEnvelope" - type: object properties: data: type: array items: $ref: "#/components/schemas/NetworkScanTarget" "500": $ref: "#/components/responses/InternalError" post: tags: [Network Scan] summary: Create network scan target description: | Creates a new network scan target. CIDR ranges are validated and capped at /20 (4096 IPs max per CIDR) to prevent accidental huge scans. operationId: createNetworkScanTarget requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NetworkScanTargetCreate" responses: "201": description: Target created content: application/json: schema: $ref: "#/components/schemas/NetworkScanTarget" "400": $ref: "#/components/responses/BadRequest" "500": $ref: "#/components/responses/InternalError" /api/v1/network-scan-targets/{id}: get: tags: [Network Scan] summary: Get network scan target description: Returns a single network scan target by ID. operationId: getNetworkScanTarget parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Network scan target details content: application/json: schema: $ref: "#/components/schemas/NetworkScanTarget" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" put: tags: [Network Scan] summary: Update network scan target description: Updates an existing network scan target. operationId: updateNetworkScanTarget parameters: - $ref: "#/components/parameters/resourceId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NetworkScanTargetCreate" responses: "200": description: Target updated content: application/json: schema: $ref: "#/components/schemas/NetworkScanTarget" "400": $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" delete: tags: [Network Scan] summary: Delete network scan target description: Deletes a network scan target. operationId: deleteNetworkScanTarget parameters: - $ref: "#/components/parameters/resourceId" responses: "204": description: Target deleted "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/network-scan-targets/{id}/scan: post: tags: [Network Scan] summary: Trigger network scan description: | Triggers an immediate scan of the specified target. Scans all configured CIDRs and ports concurrently (50 goroutines). Results feed into the discovery pipeline for deduplication. operationId: triggerNetworkScan parameters: - $ref: "#/components/parameters/resourceId" responses: "202": description: Scan completed with certificates found content: application/json: schema: $ref: "#/components/schemas/DiscoveryScan" "200": description: Scan completed, no certificates found content: application/json: schema: $ref: "#/components/schemas/StatusMessageResponse" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" # ─── Health Monitoring ───────────────────────────────────────────── /api/v1/health-checks: get: tags: [Health Monitoring] summary: List endpoint health checks description: | Lists all TLS endpoint health checks with optional filtering by status, certificate, or network scan target. Includes current status, last probe results, and probe history summary. operationId: listHealthChecks parameters: - name: status in: query schema: type: string enum: [Healthy, Degraded, Down, CertMismatch] description: Filter by health status - name: certificate_id in: query schema: type: string description: Filter by certificate ID - name: network_scan_target_id in: query schema: type: string description: Filter by network scan target ID - name: enabled in: query schema: type: boolean description: Filter by enabled/disabled state - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/per_page" responses: "200": description: List of health checks content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/EndpointHealthCheck" total: type: integer page: type: integer per_page: type: integer "500": $ref: "#/components/responses/InternalError" post: tags: [Health Monitoring] summary: Create health check description: Creates a new manual health check for an endpoint. operationId: createHealthCheck requestBody: required: true content: application/json: schema: type: object required: [endpoint, check_interval_seconds] properties: endpoint: type: string description: "host:port to monitor" example: "api.example.com:443" expected_fingerprint: type: string description: Expected certificate SHA-256 fingerprint (optional) check_interval_seconds: type: integer minimum: 30 description: Probe frequency in seconds (default 300) timeout_ms: type: integer description: TLS connection timeout in milliseconds responses: "201": description: Health check created content: application/json: schema: $ref: "#/components/schemas/EndpointHealthCheck" "400": $ref: "#/components/responses/BadRequest" "500": $ref: "#/components/responses/InternalError" /api/v1/health-checks/summary: get: tags: [Health Monitoring] summary: Health check summary description: Returns aggregate status counts for all health checks. operationId: getHealthCheckSummary responses: "200": description: Health check summary content: application/json: schema: type: object properties: healthy: type: integer degraded: type: integer down: type: integer cert_mismatch: type: integer "500": $ref: "#/components/responses/InternalError" /api/v1/health-checks/{id}: get: tags: [Health Monitoring] summary: Get health check operationId: getHealthCheck parameters: - $ref: "#/components/parameters/resourceId" responses: "200": description: Health check detail content: application/json: schema: $ref: "#/components/schemas/EndpointHealthCheck" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" put: tags: [Health Monitoring] summary: Update health check description: Update thresholds, interval, or expected fingerprint. operationId: updateHealthCheck parameters: - $ref: "#/components/parameters/resourceId" requestBody: content: application/json: schema: type: object properties: expected_fingerprint: type: string check_interval_seconds: type: integer timeout_ms: type: integer enabled: type: boolean responses: "200": description: Health check updated content: application/json: schema: $ref: "#/components/schemas/EndpointHealthCheck" "400": $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" delete: tags: [Health Monitoring] summary: Delete health check operationId: deleteHealthCheck parameters: - $ref: "#/components/parameters/resourceId" responses: "204": description: Health check deleted "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/health-checks/{id}/history: get: tags: [Health Monitoring] summary: Get probe history description: Returns historical probe records with status, response times, and errors. operationId: getHealthCheckHistory parameters: - $ref: "#/components/parameters/resourceId" - name: limit in: query schema: type: integer default: 100 minimum: 1 maximum: 1000 description: Max number of records to return responses: "200": description: Probe history content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/HealthHistoryEntry" total: type: integer "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/health-checks/{id}/acknowledge: post: tags: [Health Monitoring] summary: Acknowledge incident description: Mark a health check incident as acknowledged by the operator. operationId: acknowledgeHealthCheckIncident parameters: - $ref: "#/components/parameters/resourceId" requestBody: content: application/json: schema: type: object properties: acknowledged_by: type: string description: Operator name or ID responses: "200": description: Incident acknowledged content: application/json: schema: $ref: "#/components/schemas/EndpointHealthCheck" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" # ─── Digest ──────────────────────────────────────────────────────── /api/v1/digest/preview: get: tags: [Digest] summary: Preview digest email description: | Returns an HTML preview of the scheduled certificate digest email. This includes a summary of certificate status, pending jobs, and expiring certificates. operationId: previewDigest responses: "200": description: HTML digest email preview content: text/html: schema: type: string example: "..." "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. 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 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