From 31e50d987ff1de27c2957ad5032a26a66bea12d5 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Mon, 4 May 2026 01:35:30 +0000 Subject: [PATCH] ci: fix Rank 7 lint + openapi-handler-parity drift on master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures from the Rank 7 chain push (#438): Go Build & Test — staticcheck ST1021: internal/service/approval_metrics.go:97 comment for ApprovalDecisionEntry doesn't start with the type name internal/service/approval_metrics.go:130 comment for ApprovalPendingAgeSnapshot doesn't start with the type name Frontend Build — scripts/ci-guards/openapi-handler-parity.sh: 4 router routes have no OpenAPI operationId: GET /api/v1/approvals GET /api/v1/approvals/{id} POST /api/v1/approvals/{id}/approve POST /api/v1/approvals/{id}/reject The Rank 7 commit-3 spec deferred OpenAPI extension to commit 4 with a 'batched alongside the integration changes' note; commit 4 didn't actually add them. This commit closes that gap. Fixes: approval_metrics.go — split the doc comment that was attached to SnapshotApprovalDecisions (the function) but visually preceded ApprovalDecisionEntry (the type), so the type appeared to staticcheck as having a comment that named the function instead of the type. Same fix on ApprovalPendingAgeSnapshot. Now each exported type has its own type-name-leading comment per Go convention. api/openapi.yaml — added 4 new operationIds (listApprovalRequests, getApprovalRequest, approveApprovalRequest, rejectApprovalRequest) + new ApprovalRequest schema component under components/schemas. Inline 401 response (the Unauthorized component does not exist in this spec; the canonical pattern in the rest of the file is inline 'description: Authentication required'). The two-person integrity contract surface is documented in the description of the approve / reject endpoints so external readers see the RBAC contract from the spec alone. Verified locally: go vet ./internal/service/...: exit 0. scripts/ci-guards/openapi-handler-parity.sh: clean (140 ops vs 174 routes, 36 documented exceptions). Third CI failure (image-and-supply-chain) was a transient apt-fetch 'Connection reset by peer' from deb.debian.org while pulling libasan6_10.2.1-6_amd64.deb. Not a code issue; just re-run the workflow. No code change needed. --- api/openapi.yaml | 216 +++++++++++++++++++++++++++ internal/service/approval_metrics.go | 17 ++- 2 files changed, 227 insertions(+), 6 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index ea1412f..31f7779 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2751,6 +2751,165 @@ paths: $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/notifications: get: tags: [Notifications] @@ -4057,6 +4216,63 @@ components: $ref: "#/components/schemas/ErrorResponse" schemas: + # ─── 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 diff --git a/internal/service/approval_metrics.go b/internal/service/approval_metrics.go index 996766f..e1f4fb2 100644 --- a/internal/service/approval_metrics.go +++ b/internal/service/approval_metrics.go @@ -94,15 +94,19 @@ func (m *ApprovalMetrics) ObservePendingAge(seconds float64) { m.pendingAgeHist.observe(seconds) } -// SnapshotApprovalDecisions returns the current decision counter table -// as a sorted slice for deterministic Prometheus exposition. Sort key -// is (outcome, profile_id). +// ApprovalDecisionEntry is a single row of the SnapshotApprovalDecisions +// output — the (outcome, profile_id) tuple plus the cumulative count. +// Used by the Prometheus exposer to emit +// certctl_approval_decisions_total{outcome,profile_id} samples. type ApprovalDecisionEntry struct { Outcome string ProfileID string Count uint64 } +// SnapshotApprovalDecisions returns the current decision counter table +// as a sorted slice for deterministic Prometheus exposition. Sort key +// is (outcome, profile_id). func (m *ApprovalMetrics) SnapshotApprovalDecisions() []ApprovalDecisionEntry { if m == nil { return nil @@ -127,9 +131,10 @@ func (m *ApprovalMetrics) SnapshotApprovalDecisions() []ApprovalDecisionEntry { return out } -// SnapshotApprovalPendingAgeHistogram returns the current bucket counts -// + sum + total count for the pending-age histogram. Format suits the -// Prometheus histogram exposition (le buckets + _sum + _count). +// ApprovalPendingAgeSnapshot is the snapshot output of +// SnapshotApprovalPendingAgeHistogram — bucket bounds + cumulative +// counts + sum + total count. Format suits the Prometheus histogram +// exposition (le buckets + _sum + _count). type ApprovalPendingAgeSnapshot struct { BucketBounds []float64 // [60, 300, 1800, 3600, 21600, 86400] — exclusive of +Inf BucketCounts []uint64 // cumulative counts per bucket; len = len(BucketBounds) + 1 (last is +Inf)