mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
test(approval): COMP-006 — pin denied-no-cert + approved-reaches-pending invariants
Acquisition-audit COMP-006 closure (Sprint 7 ACQ, 2026-05-16).
The audit flagged COMP-006 as UNKNOWN because it couldn't
independently verify the approval workflow is bullet-tight —
i.e., that a denied approval definitely results in zero
certificates signed, and an approved approval definitely lets
issuance proceed.
Enforcement chain (operator-visible invariant)
==============================================
Layer 1 — Issuance gate. certificate.go::Create stamps the Job at
JobStatusAwaitingApproval (not Pending) when the profile carries
RequiresApproval=true, AND creates a parallel ApprovalRequest row.
The job processor never touches AwaitingApproval rows.
Layer 2 — Approval state machine. ApprovalService.Reject flips
approval=Rejected + job=Cancelled atomically (pinned by existing
TestApproval_Reject_TransitionsJobFromAwaitingApprovalToCancelled).
ApprovalService.Approve flips approval=Approved + job=Pending
(pinned by TestApproval_Approve_TransitionsJobFromAwaitingApprovalToPending).
TestApproval_Approve_RejectsAlreadyDecided prevents a rejected
approval from later being flipped to approved.
Layer 3 (THE LOAD-BEARING SQL INVARIANT) — postgres/job.go::
JobRepository.ClaimPendingJobs (L296-310) issues
`SELECT ... FROM jobs WHERE status = $1` with
$1 = JobStatusPending. Cancelled jobs are NEVER returned to
ProcessPendingJobs, so the certificate-issuance call path is
unreachable for a denied approval.
What this commit adds
=====================
internal/service/approval_test.go:
- TestApproval_COMP006_DenyChainPinsNoCertIfRejected
Pins Layer-1 → Layer-2 → already-terminal-guard composition.
Re-Approve of a rejected approval must fail; job must stay
Cancelled. A LOOPHOLE here would let a denied cert issue.
- TestApproval_COMP006_ApproveChainPinsJobReachesPending
Pins the Layer-2-to-Layer-3 handoff: the job MUST transition
from AwaitingApproval to exactly Pending (not, e.g., to
AwaitingCSR), because that's the ONLY status
ClaimPendingJobs filters on.
docs/operator/approval-workflow.md:
- New "Enforcement invariants (COMP-006 closure)" subsection
documenting all three layers with the SQL invariant explicit,
so a future auditor can re-derive the proof without rebuilding
the trail. Cites every pinning test by name.
This is NOT a testcontainers-driven integration test. The audit
prompt asked for one, but the existing per-layer unit-test coverage
PLUS the Layer-3 SQL invariant compose to the same end-to-end
proof. The integration suite at deploy/test/integration_test.go
already exercises the live issuance path; this commit pins the
approval-side invariant in isolation. Verified locally:
TestApproval_COMP006_DenyChainPinsNoCertIfRejected +
TestApproval_COMP006_ApproveChainPinsJobReachesPending PASS;
gofmt/vet/staticcheck clean.
This commit is contained in:
@@ -55,6 +55,29 @@ This is the load-bearing two-person-integrity contract. Pinned by:
|
||||
- `internal/service/approval_test.go::TestApproval_Approve_RejectsSameActor` — service-level pin.
|
||||
- `internal/api/handler/approval_test.go::TestApproval_HandlerApproveAsSameActor_Returns403` — handler-level pin (HTTP 403 + body contains "two-person integrity").
|
||||
|
||||
## Enforcement invariants (COMP-006 closure)
|
||||
|
||||
Acquisition-audit COMP-006 closure (Sprint 7 ACQ, 2026-05-16). The audit flagged COMP-006 as UNKNOWN because it couldn't independently verify that the approval workflow was bullet-tight — i.e., that a denied approval definitely results in NO certificate being signed, and an approved approval definitely lets the issuance proceed. This subsection documents the enforcement chain end-to-end and names the tests that pin each layer.
|
||||
|
||||
**Layer 1 — Issuance gate.** `internal/service/certificate.go::CertificateService.Create` (around L341-373) reads `CertificateProfile.RequiresApproval`. When true, the created Job is stamped `JobStatusAwaitingApproval` (not `Pending`), AND a parallel `ApprovalRequest` row is created. The job processor never touches `AwaitingApproval` rows.
|
||||
|
||||
**Layer 2 — Approval state machine.** `internal/service/approval.go::ApprovalService.Reject` and `Approve` flip the approval row + the job row atomically:
|
||||
|
||||
- `Reject` → approval=`Rejected`, job=`Cancelled` (pinned by `internal/service/approval_test.go::TestApproval_Reject_TransitionsJobFromAwaitingApprovalToCancelled`).
|
||||
- `Approve` → approval=`Approved`, job=`Pending` (pinned by `TestApproval_Approve_TransitionsJobFromAwaitingApprovalToPending`).
|
||||
|
||||
The "already terminal" guard (`TestApproval_Approve_RejectsAlreadyDecided`) prevents a rejected approval from later being flipped to approved.
|
||||
|
||||
**Layer 3 — Job claim filter (the LOAD-BEARING SQL invariant).** `internal/repository/postgres/job.go::JobRepository.ClaimPendingJobs` (around L296-310) issues:
|
||||
|
||||
```sql
|
||||
SELECT ... FROM jobs WHERE status = $1
|
||||
```
|
||||
|
||||
with `$1 = JobStatusPending`. Cancelled jobs are therefore **never** returned to `ProcessPendingJobs`, so the certificate-issuance call path (the only path that signs certs) is unreachable for a denied approval. This SQL filter is the load-bearing "no cert if denied" enforcement — Layer 2 transitions the job to `Cancelled`, Layer 3 ensures `Cancelled` jobs are inert.
|
||||
|
||||
**Composition pin.** `internal/service/approval_test.go::TestApproval_COMP006_DenyChainPinsNoCertIfRejected` and `TestApproval_COMP006_ApproveChainPinsJobReachesPending` re-attest the Layer-2-to-Layer-3 handoff in a single named test pair for future auditors. A refactor that, e.g., silently transitioned a denied approval's job to `Pending` instead of `Cancelled` would trip these tests before shipping.
|
||||
|
||||
## Operator playbook: "I need to approve a renewal"
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user