Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0789c27ca2 | |||
| 184f33c60a | |||
| a6510e7afc | |||
| c0cb1887ed | |||
| 139cb5077a | |||
| adc55a17ab | |||
| 0d8913a299 | |||
| 4697692ce7 | |||
| 23b8ff81d3 | |||
| d0a8c363b2 | |||
| 04053df16e | |||
| bcbeb7badb | |||
| 1f4f7f0f81 | |||
| df2b83c86b |
+25
-15
@@ -1,20 +1,30 @@
|
||||
{
|
||||
"phase": 4,
|
||||
"stage": "complete",
|
||||
"milestone": "v1.28",
|
||||
"phase_role": "execution",
|
||||
"phase": 0,
|
||||
"stage": "grill",
|
||||
"milestone": "v1.29",
|
||||
"phase_role": "pre_execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-19T23:15:00Z",
|
||||
"updated_at": "2026-08-20T00:40:00Z",
|
||||
"project": "acdl",
|
||||
"projects": ["acdl", "nova-blockchain-exchange"],
|
||||
"active_milestone": "v1.28",
|
||||
"milestone_branch": "milestone/v1.28-cli-identity",
|
||||
"phase_branch": "phase/04-token-vend-pat",
|
||||
"tag_line": "v1.27.x",
|
||||
"phase_name": "token-vend-pat",
|
||||
"reqs_covered": ["REQ-336", "REQ-337", "REQ-338", "REQ-339", "REQ-340", "REQ-341", "REQ-342", "REQ-343", "REQ-344"],
|
||||
"caps_verified": ["CAP-037", "CAP-038"],
|
||||
"tests": {"p4_specific": 54, "total_passing": 998, "failures": 0},
|
||||
"grill_conditions_resolved": ["C-6.1/C-7.1 ABAC fail-closed", "C-5.1 requested_claims shape", "C-7.3 cred file no raw PAT", "C-8.2 kj pinned", "C-2.1 P5 folded into P4 W8"],
|
||||
"notes": "v1.28 P4 SHIP. token-vend-pat complete (highest-risk, double-length, 8 waves). Tag v1.27.4. 9 REQs covered (REQ-336..344 + REQ-340/341 folded), CAP-037 + CAP-038 verified. ABAC fail-closed (7 tests), KMS ES256 DER->raw, JWKS, PAT lifecycle, nova auth, nova idp setup. 54 P4 tests + 998 total. Next: P5 docs-integration."
|
||||
"active_milestone": "v1.29",
|
||||
"milestone_branch": "milestone/v1.29-reposplit-identity",
|
||||
"phase_branch": "phase/00-pre-execution",
|
||||
"tag_line": "v1.28.x",
|
||||
"phase_name": "pre-execution",
|
||||
"milestone_type": "feature",
|
||||
"reqs_covered": [],
|
||||
"reqs_partial": [],
|
||||
"decisions": ["D-232", "D-233", "D-234", "D-235", "D-236", "D-237", "D-238", "D-239", "D-240"],
|
||||
"carry_forward": ["Q7 (kj image verification dependency — M1.5 gate, verified in nova-platform-ops CI)"],
|
||||
"personas": ["lead-developer", "backend-engineer", "security-engineer", "cli-engineer", "data-engineer"],
|
||||
"grill": {
|
||||
"verdict": "PROCEED-WITH-CONDITIONS",
|
||||
"confidence": 0.72,
|
||||
"critical_fixes": 4,
|
||||
"tracked_conditions": 6,
|
||||
"binding_decisions": ["G-1", "G-2.1", "G-2.2", "G-3", "G-4", "G-5"],
|
||||
"critical_fix_ids": ["CF-1", "CF-2", "CF-3", "CF-4"]
|
||||
},
|
||||
"notes": "v1.29 GRILL complete. PROCEED-WITH-CONDITIONS 0.72. 4 critical fixes applied to PLAN.md: CF-1 (M1.5 hard P6 ship gate, spike 8->12 items), CF-2 (covered-reference REQs gated by operator-attested Result column), CF-3 (P1 pushes v1.29.0 intermediate tag, P5 smoke no hedge), CF-4 (kj source-fetch confirmed before P1 Wave 1, recorded in kj-version.txt). 6 tracked conditions (TC-1..TC-6). Covered-reference pattern accepted as verification surface ONLY with CF-1+CF-2 (G-1)."
|
||||
}
|
||||
+225
-1
@@ -273,4 +273,228 @@ new ("introducing Nova-idp"). The mis-framing was in calling them
|
||||
All material ambiguities resolved at full autonomy (6 open questions +
|
||||
5 grounding gaps → D-226..D-231, confidence ≥ 0.80). No human escalation
|
||||
triggered (all confidences ≥ 0.60 threshold). REQUIREMENTS.md updated
|
||||
with the decision ledger + invariants. Next: RESEARCH.
|
||||
with the decision ledger + invariants. Next: RESEARCH.
|
||||
|
||||
---
|
||||
|
||||
# CLARIFY — v1.29 Reposplit + Identity Layer Bring-Live
|
||||
|
||||
> **Autonomy:** full. Auto-resolution with assumption logging per
|
||||
> `config.autonomy.level: "full"`. No human escalation unless confidence
|
||||
> < 0.60. The v1.29 spec is v1.1 (highly detailed — §7 resolves Q1-6, Q7
|
||||
> carried forward as a verification-gate dependency). This file records
|
||||
> the v1.29 ambiguities and the scope-split grounding.
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
The v1.29 spec ("Universal Feature Specification — Reposplit + Identity
|
||||
Layer Bring-Live", v1.1) is the most detailed spec the project has
|
||||
received: it includes BDD acceptance criteria, an 8-item M1.5 spike
|
||||
checklist, 7 decisions pre-drafted (D-232..238), 14 NFRs, and an
|
||||
explicit §7 resolving Q1-6. Clarify work focuses on (a) the scope split
|
||||
between `acdl` (CIAgent) and `nova-platform-ops` (out-of-band), (b) the
|
||||
`kj` identity (Go binary vs. the v1.28 kyverno-json re-mapping), and (c)
|
||||
the carried-forward Q7. Each ambiguity gets a decision ID (D-232+,
|
||||
continuing from v1.28's D-226..D-231), a resolution, a confidence score,
|
||||
and a rationale.
|
||||
|
||||
---
|
||||
|
||||
## Prior-conversation resolutions (already locked, restated for the record)
|
||||
|
||||
These were resolved by the user-approved execution plan in the
|
||||
conversation that spawned v1.29.
|
||||
|
||||
### Q-P1 — The spec creates a separate repo `nova-platform-ops`. CIAgent runs inside `acdl`. Where does the Terraform code land?
|
||||
|
||||
**Resolution:** Terraform modules
|
||||
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`) are
|
||||
authored **out-of-band** in `nova-platform-ops` (operator-owned). CIAgent
|
||||
in `acdl` delivers only the acdl-side work (publish.yml, Gitea scrub,
|
||||
CFN archive, operator guide, consumer bump) and tracks the ops-side
|
||||
REQs as **covered-reference** (verification surface = the M1/M1.5/M2
|
||||
cutover gates documented in the operator guide).
|
||||
**Confidence:** 1.0 (user-confirmed — "Author out-of-band in
|
||||
nova-platform-ops"). **Decision:** scope split documented in
|
||||
PROJECT.md §v1.29 + REQUIREMENTS.md §v1.29.
|
||||
|
||||
### Q-P2 — The run scope. How far does this `/ci-run` go?
|
||||
|
||||
**Resolution:** Full milestone through the final phase (P0 → P1..P5 →
|
||||
P6 final review + audit + milestone ship, tag `v1.28.6`).
|
||||
**Confidence:** 1.0 (user-confirmed — "Full milestone through final
|
||||
phase"). **Decision:** n/a (execution scope, not a D-ID).
|
||||
|
||||
### Q-P3 — Edge 8 / REQ-354 footnote: pilot consumer deploy bump. Handle how?
|
||||
|
||||
**Resolution:** Include a cross-project phase (P5) in this CIAgent run
|
||||
(multi-project mode is active). Bump `nova-blockchain-exchange`
|
||||
deploy.yml `@v1.25` → `@v1.29` + smoke test.
|
||||
**Confidence:** 1.0 (user-confirmed — "Cross-project phase in this
|
||||
run"). **Decision:** n/a (execution scope).
|
||||
|
||||
---
|
||||
|
||||
## Spec-grounded resolutions (from §7 + §5)
|
||||
|
||||
### Q1 — State bucket bootstrap on day-0 (resolved per spec §7.1)
|
||||
|
||||
**Resolution:** Manual one-time at the operator's secure scratch; Terraform
|
||||
then adopts it via `terraform import`. Avoids bootstrapping the
|
||||
bootstrapper. **Confidence:** 1.0 (spec §7.1 explicit). **Decision:**
|
||||
D-235 (tag-pin handoff) — the state bucket is one of the imported
|
||||
resources.
|
||||
|
||||
### Q2 — `pyproject.toml` version bump (resolved per spec §7.2)
|
||||
|
||||
**Resolution:** Bump to `1.29.0` in M1 (P2 — Gitea scrub phase) of v1.29
|
||||
alongside the Gitea scrub. **Confidence:** 1.0 (spec §7.2 explicit).
|
||||
**Decision:** n/a (implementation detail, tracked in PLAN.md P2).
|
||||
|
||||
### Q3 — WAF cost (resolved per spec §7.3)
|
||||
|
||||
**Resolution:** Acceptable for the JWKS public surface; documented in
|
||||
operator-guide cost section (~$5–10/month per WebACL + per-request).
|
||||
**Confidence:** 1.0 (spec §7.3 explicit). **Decision:** documented in
|
||||
REQ-OPS-GUIDE AC.
|
||||
|
||||
### Q4 — Coverage 73.8% — does this milestone drive it down further? (resolved per spec §7.4)
|
||||
|
||||
**Resolution:** Accept any further debt as carry-forward to the separate
|
||||
NFR milestone. New modules have ≥80% coverage; older code paths are
|
||||
unchanged. YELLOW carried without scope expansion. **Confidence:** 1.0
|
||||
(spec §7.4 explicit). **Decision:** n/a (NFR carry-forward, not a v1.29
|
||||
D-ID).
|
||||
|
||||
### Q5 — CFN code deletion timing (resolved per spec §7.5)
|
||||
|
||||
**Resolution:** Archive to `docs/archive/nova-idp-cfn-v1.28.md`; deletion
|
||||
is a follow-up after the next pilot run verifies Terraform parity.
|
||||
**Confidence:** 1.0 (spec §7.5 explicit). **Decision:** REQ-369 AC (3).
|
||||
|
||||
### Q6 — `acdl-act-runner-role` reuse (resolved per spec §7.6)
|
||||
|
||||
**Resolution:** Reuse the existing role for v1.29 to minimize IAM surface
|
||||
changes; scope narrow per REQ-360. **Confidence:** 1.0 (spec §7.6
|
||||
explicit). **Decision:** covered by REQ-360 (IAM-NARROW).
|
||||
|
||||
### Q7 — `kj` image verification dependency (CARRY-FORWARD per spec §7.7)
|
||||
|
||||
**Resolution (carry-forward):** M1 cutover is conditional on the M1.5
|
||||
verification gate. **Recommendation:** Block M1 cutover until M1.5
|
||||
passes. If M1.5 fails three consecutive rebuilds, defer to M2a and ship
|
||||
Nova-idp in read-only partial mode (no token issuance) until `kj` is
|
||||
verified. **Impact if wrong:** A live token-vend that signs with a
|
||||
broken ABAC path would let through a denied claim — fails closed only if
|
||||
`ImageUri` is verified pre-apply. **Confidence:** 0.92 (spec §7.7
|
||||
explicit + D-236 cutover shape). **Decision:** D-236 (cutover shape +
|
||||
rollback procedure). This is the **only** outstanding carry-forward;
|
||||
CIAgent in acdl builds + publishes the image + the gate tests (P1), but
|
||||
the live 3-rebuild verification happens in `nova-platform-ops` CI
|
||||
(out-of-band). CIAgent does not block on it.
|
||||
|
||||
---
|
||||
|
||||
## Grounding-gap resolutions (surfaced in pre-flight)
|
||||
|
||||
### G1 — The spec's `kj` vs. v1.28's `kj` re-mapping
|
||||
|
||||
**Ambiguity:** v1.28 (D-227) re-mapped the spec's `kj` engine →
|
||||
kyverno-json (INV-4 swappable), explicitly stating "no new `kj` engine
|
||||
is built." v1.29 reintroduces `kj` as a compiled Go binary
|
||||
(`platform/abac/kj-version.txt`, pinned v0.0.3) embedded in an ECR
|
||||
container image. Is this a contradiction?
|
||||
|
||||
**Resolution:** No contradiction. v1.28's `kj` was a *policy engine*
|
||||
reference; v1.29's `kj` is a *compiled Go binary* (a distinct artifact).
|
||||
The kyverno-json engine remains the policy engine (INV-4). The v1.29
|
||||
`kj` binary is invoked via `subprocess.run(['/opt/kj/kj', 'apply', ...])`
|
||||
by the Lambda handler — it is a **substrate** binary, not a policy
|
||||
engine. The two coexist: kyverno-json evaluates ABAC policy; `kj` is the
|
||||
container image's static binary that the Lambda runtime executes. No
|
||||
collision.
|
||||
**Confidence:** 0.95 (spec §3.3 Edge 5 item 4 explicit + v1.28 D-227
|
||||
scope). **Decision:** documented in PROJECT.md §v1.29 ID allocations +
|
||||
KJ-STATIC NFR.
|
||||
|
||||
### G2 — `REQ-363b` sub-requirement numbering
|
||||
|
||||
**Ambiguity:** The spec uses `REQ-363b` for the Fargate defensive
|
||||
fallback. The repo's REQ namespace is `REQ-NNN` (numeric). How to
|
||||
record `363b`?
|
||||
|
||||
**Resolution:** Keep `REQ-363b` as-is (sub-requirement of REQ-363). It
|
||||
is a distinct requirement (Fargate fallback, KJ-LOCKSTEP) but logically
|
||||
paired with REQ-363 (production substrate). The `b` suffix is
|
||||
unambiguous and matches the spec. No collision with any existing REQ.
|
||||
**Confidence:** 0.98 (spec explicit + no collision). **Decision:** n/a
|
||||
(naming convention).
|
||||
|
||||
### G3 — `REQ-370` gap
|
||||
|
||||
**Ambiguity:** The spec jumps from REQ-369 to REQ-371. Is REQ-370
|
||||
missing or intentionally unused?
|
||||
|
||||
**Resolution:** Intentionally unused per the source spec. REQ-370 is a
|
||||
gap in the spec's numbering (likely a deleted/renumbered item during
|
||||
spec v1.0 → v1.1). v1.29 does not allocate REQ-370; it remains a
|
||||
reserved gap. **Confidence:** 0.90 (spec explicit gap, no content).
|
||||
**Decision:** n/a (spec fidelity).
|
||||
|
||||
### G4 — Covered-reference REQs and CIAgent verification
|
||||
|
||||
**Ambiguity:** REQ-355, 356, 357, 358, 359, 360, 361, 362, 363, 363b,
|
||||
364, 365, 366, 371 are authored in `nova-platform-ops` (out-of-band).
|
||||
How does CIAgent verify them? Are they `human_needed`?
|
||||
|
||||
**Resolution:** They are **covered-reference**, NOT `human_needed`. The
|
||||
verification surface is the M1/M1.5/M2 cutover gates documented in the
|
||||
operator guide (`docs/operator-guide-platform-ops.md`). The operator
|
||||
guide lists each covered-reference REQ with its cutover gate entry
|
||||
(M1/M1.5/M2). CIAgent verify marks them `covered-reference` and the
|
||||
final-phase audit confirms the operator guide documents all gates.
|
||||
**Confidence:** 0.94 (scope-split decision + spec §2.3 milestone
|
||||
gates). **Decision:** documented in REQUIREMENTS.md §v1.29 + REQ-OPS-
|
||||
GUIDE AC.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions (logged, not escalated — confidence ≥ 0.80)
|
||||
|
||||
1. **`kj` v0.0.3** is available at the pinned SHA in
|
||||
`platform/abac/kj-version.txt` and compiles with `CGO_ENABLED=0
|
||||
GOOS=linux GOARCH=amd64`. RESEARCH will confirm the source repository
|
||||
+ build commands. If the binary is not available, P1 (publish
|
||||
pipeline) cannot produce the ECR image; M1.5 gate fails by
|
||||
construction → M2a (Fargate toggle, same image) also fails → escalate
|
||||
(but this is a spec dependency, not a CIAgent ambiguity).
|
||||
2. **ECR repository** exists or is creatable in account `581513795199`
|
||||
for the `kj` image. RESEARCH will confirm. The repo name is not
|
||||
specified in the spec; the operator guide will document it.
|
||||
3. **GitHub Releases** is the artifact distribution channel (per
|
||||
REQ-354). The `acdl/acdl` repo is already on GitHub (the Gitea scrub
|
||||
in REQ-367 standardizes on GitHub). NOVA_FORGE_TOKEN (Gitea) is
|
||||
retained for `nova-platform-ops` releases only.
|
||||
4. **The `nova idp setup --apply` terraform-delegation** (REQ-369 AC 2)
|
||||
requires `terraform` to be on the operator's PATH. The CLI detects
|
||||
terraform via `which terraform`; if absent, it falls back to the CFN
|
||||
path with a deprecation warning (the CFN archive remains read-only
|
||||
reference, but the delegation is the preferred path).
|
||||
5. **The M1.5 8-item spike** (spec §3.3 Edge 5) is the verification
|
||||
gate. CIAgent in acdl authors the *tests* (test_idp_auth,
|
||||
test_kms_roundtrip, ABAC E2E) in P1; the *live 3-rebuild run*
|
||||
happens in `nova-platform-ops` CI. This is the Q7 carry-forward
|
||||
surface.
|
||||
|
||||
---
|
||||
|
||||
## CLARIFY complete
|
||||
|
||||
All material ambiguities resolved at full autonomy (3 prior-conversation
|
||||
+ 7 spec-grounded + 4 grounding-gap → D-232..D-238, confidence ≥ 0.80).
|
||||
Q7 is the only carry-forward (verification-gate dependency, not a
|
||||
blocking ambiguity). No human escalation triggered (all confidences ≥
|
||||
0.60 threshold). REQUIREMENTS.md updated with the decision ledger +
|
||||
invariants + NFR constraints. Next: RESEARCH.
|
||||
+250
-1
@@ -107,4 +107,253 @@ required (full autonomy).
|
||||
|
||||
The plan proceeds with the 3 critical fixes and 16 tracked conditions
|
||||
applied to PLAN.md + REQUIREMENTS.md. The binding decisions above are
|
||||
the authoritative grill record. Next: MVP/UX CHECK → SHIP phase 0.
|
||||
the authoritative grill record. Next: MVP/UX CHECK → SHIP phase 0.
|
||||
|
||||
---
|
||||
|
||||
# GRILL — v1.29 Reposplit + Identity Layer Bring-Live
|
||||
|
||||
> Adversarial red-team review of the v1.29 SPECIFY + CLARIFY +
|
||||
> RESEARCH + PLAN. Griller: CIAgent griller (red-team persona).
|
||||
> Autonomy: full. All 9 review axes grilled; every claim verified
|
||||
> against the live codebase (`publish.yml`, `kj-version.txt`,
|
||||
> `nova/idp/setup.py`, existing v1.28 test files).
|
||||
> Date: 2026-08-20.
|
||||
|
||||
---
|
||||
|
||||
## Overall verdict: **PROCEED-WITH-CONDITIONS** · Confidence 0.72
|
||||
|
||||
The plan is architecturally sound and the in-acdl scope is well-bounded.
|
||||
The scope split (Terraform out-of-band in `nova-platform-ops`, acdl
|
||||
authors publish/scrub/archive/guide/consumer-bump) is the correct
|
||||
boundary per Vision §4. The technical depth is accurate (D-239 ECR tag
|
||||
correction, D-240 Terraform precondition floor, CloudFront OAC pitfall,
|
||||
ECR tag mutability → pin-by-digest). The cost envelope is realistic.
|
||||
|
||||
**However**, the covered-reference pattern — as currently structured —
|
||||
is a **deferred-trust assertion** for 14 of 17 requirements. The plan
|
||||
ships REQ-355..366 + 371 as "complete" on the strength of a markdown
|
||||
pointer (the operator guide's cutover-gate section) to CI in a repo
|
||||
that does not yet exist and has no CIAgent presence. The M1.5
|
||||
verification gate, the one surface acdl genuinely owns, can be
|
||||
authored-but-never-run-green and the milestone still ships. Four
|
||||
critical fixes convert "documented" into "evidenced-by-operator-
|
||||
attestation-in-the-guide-which-acdl-audits-at-P6."
|
||||
|
||||
**4 critical fixes (must apply before EXECUTE) + 6 tracked conditions.**
|
||||
No escalations (all axes resolved at confidence ≥ 0.60; the user
|
||||
confirmed the binding verdict on the covered-reference pattern).
|
||||
|
||||
---
|
||||
|
||||
## Axis verdicts
|
||||
|
||||
| Axis | Verdict | Confidence | Forcing finding |
|
||||
|------|---------|-----------|----------------|
|
||||
| §1 Feasibility | PROCEED-WITH-CONDITIONS | 0.70 | KJ-SOURCE: `kj` v0.0.3 source repo unverified by RESEARCH (CF-1) |
|
||||
| §2 Scope | PROCEED-WITH-CONDITIONS | 0.74 | Covered-reference = deferred-trust for 14/17 REQs (G-1 + CF-2) |
|
||||
| §3 Cost | PROCEED | 0.82 | $30-40/month realistic at pilot volume; no hidden budget shock |
|
||||
| §4 Requirements coverage | PROCEED-WITH-CONDITIONS | 0.76 | All REQs mapped; covered-reference verification surface weak (CF-2) |
|
||||
| §5 Technical risks | PROCEED-WITH-CONDITIONS | 0.72 | KJ-STATIC mitigation sound; KJ-LOCKSTEP by-construction good; M1.5 gate not enforced (CF-1) |
|
||||
| §6 Testability | REJECT-AS-WRITTEN → PROCEED-WITH-CONDITIONS | 0.66 | "Verified via cutover gates in operator guide" is a punt absent CF-1/CF-2/CF-3/CF-4 |
|
||||
| §7 Security | PROCEED-WITH-CONDITIONS | 0.68 | INV-18 (AuthType=AWS_IAM), TFM-HITL, IAM-NARROW unverifiable from acdl (CF-2) |
|
||||
| §8 Timeline/sequencing | PROCEED | 0.80 | P1→P2 ordering safe (acdl-local scrub); P5 smoke hedges (CF-3) |
|
||||
| §9 Adversarial | PROCEED-WITH-CONDITIONS | 0.70 | Dominant silent-failure = M1.5 never runs green (CF-1 addresses) |
|
||||
|
||||
---
|
||||
|
||||
## Critical fixes (must apply before EXECUTE)
|
||||
|
||||
### 🔴 CF-1 — M1.5 green is a HARD P6 milestone-ship gate; spike extended
|
||||
|
||||
**Finding:** P1 authors the M1.5 gate tests (Wave 3) but P1's exit
|
||||
criterion explicitly marks the live KMS round-trip as "covered-
|
||||
reference, runs in nova-platform-ops CI." P6 ships the milestone with
|
||||
no requirement that M1.5 ever ran green. The dominant silent-failure
|
||||
path (user-confirmed): M1.5 never runs green → 14 REQs ship "complete"
|
||||
on paper while Nova-idp is not live.
|
||||
|
||||
**Fix (binding):**
|
||||
1. P6 Wave 2 (`ciagent-ship`) MUST NOT ship `v1.28.6` until the operator
|
||||
guide (`docs/operator-guide-platform-ops.md`) contains an
|
||||
operator-attested "M1.5 Verification Gate Result" row recording:
|
||||
(a) the 8-item spike all-green on **3 consecutive rebuilds** in
|
||||
`nova-platform-ops` CI; (b) the rebuild run IDs / commit SHAs; (c)
|
||||
the operator attestor identity. The P6 audit step (Wave 1) verifies
|
||||
this row exists + is non-empty. Absent the row → P6 blocks → escalate.
|
||||
2. The M1.5 8-item spike (PLAN Happy Path §3.3 Edge 5) is EXTENDED from
|
||||
8 to **12 items** by adding:
|
||||
- **Item 9 (JWKS-EDGE-ONLY):** direct JWKS Function URL GET (bypassing
|
||||
CloudFront) returns **403**; via-CloudFront GET returns 200. Proves
|
||||
`AuthType: AWS_IAM` + OAC pinning (INV-18). Without this, the
|
||||
`AuthType: NONE` pitfall (RESEARCH §4) is undetected.
|
||||
- **Item 10 (IAM-NARROW):** `aws iam get-role-policy` on the OIDC
|
||||
role asserts no `Action: "*"` and no `Resource: "*"` (REQ-360).
|
||||
- **Item 11 (TFM-HITL):** a `terraform apply` `workflow_dispatch`
|
||||
triggered by the PR author is **rejected** (exit non-zero,
|
||||
`gitea.triggering_actor == PR author`); a dispatch by a distinct
|
||||
user proceeds (REQ-357, RESEARCH §10).
|
||||
- **Item 12 (rollback drill):** revert `nova_platform_version` pin →
|
||||
`terraform apply` → assert the prior ECR digest runs (proves D-236
|
||||
rollback; guards against ECR tag mutability, RESEARCH §2).
|
||||
|
||||
**Binding decision G-2.1:** the covered-reference pattern is accepted
|
||||
as a verification surface **only** with CF-1 applied. M1.5 green
|
||||
(evidenced by operator attestation in the guide) is the ship gate.
|
||||
|
||||
### 🔴 CF-2 — Covered-reference REQs gated by operator-attested evidence rows
|
||||
|
||||
**Finding:** 14 of 17 REQs (355..366, 371) are "verified via cutover
|
||||
gates in the operator guide" (CLARIFY G4). This is a deferred-trust
|
||||
assertion: if `nova-platform-ops` is never built, or builds the wrong
|
||||
thing, or its CI silently passes, the REQs ship "complete" on the
|
||||
strength of a markdown pointer. The user confirmed this is a
|
||||
deferred-trust assertion, not a verification.
|
||||
|
||||
**Fix (binding):** The operator guide (P4 Wave 1 Task 1.1) "Cutover
|
||||
Gates" section MUST list each covered-reference REQ with:
|
||||
(a) the gate entry (M1/M1.5/M2); (b) the verification command; (c) a
|
||||
placeholder "Result" column. The P6 audit step (Wave 1) verifies that
|
||||
every covered-reference REQ has a non-empty, green "Result" entry
|
||||
(operator-attested). A REQ with an empty or red Result → P6 blocks.
|
||||
This converts "documented" to "evidenced-by-operator-attestation-
|
||||
audited-by-acdl-at-P6."
|
||||
|
||||
**Binding decision G-1:** the covered-reference pattern is **accepted
|
||||
as a verification surface** with CF-1 + CF-2 applied. Without them, it
|
||||
is a punt and the grill would REJECT.
|
||||
|
||||
### 🔴 CF-3 — P5 smoke test must run against a real v1.29.x tag (no hedge)
|
||||
|
||||
**Finding:** P5 bumps the consumer deploy.yml `@v1.25` → `@v1.29` and
|
||||
runs a smoke test "against the v1.29 publish artifacts." But
|
||||
`publish.yml` triggers on `v1.29.*` tags (P1 Wave 0), and the milestone
|
||||
release tag is `v1.28.6`. P5 Wave 1 Task 1.2 hedges: "If the v1.29
|
||||
publish artifacts are not yet available... mark as covered-reference:
|
||||
requires v1.29.0 tag." This hedge lets P5 ship green without the
|
||||
smoke test ever running against real artifacts — a second silent-
|
||||
failure path.
|
||||
|
||||
**Fix (binding):**
|
||||
1. P1 Wave 4 (regression + ship) MUST push a `v1.29.0` tag (or the
|
||||
first `v1.29.x` tag) as part of P1 ship, triggering `publish.yml`
|
||||
and producing the v1.29 artifacts. Document this in PLAN P1.
|
||||
2. P5 Wave 1 Task 1.2's hedge clause is REMOVED. The P5 smoke test
|
||||
MUST run against the published v1.29.x artifacts. If the artifacts
|
||||
are absent (P1 failed to publish), P5 fails closed — no hedge to
|
||||
"covered-reference."
|
||||
3. The milestone release tag remains `v1.28.6` (the v1.28.x line per
|
||||
the tagging convention); the `v1.29.0` artifact tag is a P1
|
||||
intermediate tag, not the release. This resolves the tag-semantics
|
||||
ambiguity the grill surfaced.
|
||||
|
||||
### 🔴 CF-4 — kj v0.0.3 source-fetch path confirmed before P1 Wave 1
|
||||
|
||||
**Finding:** P1 Wave 1 Task 1.1b says "fetches the `kj` Go source at
|
||||
the pinned SHA" citing "RESEARCH §7 — source repo confirmed in P1
|
||||
RESEARCH." RESEARCH §7 confirms the build command (`CGO_ENABLED=0`)
|
||||
but is **silent on the source repository**. Assumption ledger item #1
|
||||
says "RESEARCH will confirm the source repository + build commands"
|
||||
— RESEARCH did NOT confirm the source repo. `kj-version.txt` pins
|
||||
`v0.0.3` + SHA `4ebb9a19...` but the grill cannot determine whether
|
||||
this is a source commit SHA or a binary digest, or what repo it lives
|
||||
in. P1 Wave 1 is built on an open assumption.
|
||||
|
||||
**Fix (binding):** Before P1 Wave 1 starts (P1 Wave 0 or a new Wave
|
||||
0.5), the backend-engineer MUST confirm: (a) the `kj` source repo URL
|
||||
+ the commit at SHA `4ebb9a19...`; (b) `go build` reproduces a binary
|
||||
whose SHA-256 matches the recorded one (or the SHA is a source commit,
|
||||
in which case the build is the verification); (c) the fetched source
|
||||
compiles `CGO_ENABLED=0` to a statically-linked binary (KJ-STATIC). If
|
||||
the source is not fetchable at the pinned SHA → P1 fails closed →
|
||||
escalate (this is a spec dependency, not a CIAgent ambiguity per
|
||||
assumption #1). Document the confirmed repo URL + commit in
|
||||
`platform/abac/kj-version.txt` (add a third line: the source repo URL).
|
||||
|
||||
---
|
||||
|
||||
## Tracked conditions (apply during execution)
|
||||
|
||||
- **TC-1 (KJ-STATIC audit, P1 Wave 1 Task 1.2):** `file(1)` asserts
|
||||
`statically linked` + `readelf -d` asserts no `NEEDED` entries, as a
|
||||
CI gate. Already in PLAN; tracked for enforcement.
|
||||
- **TC-2 (KJ-LOCKSTEP by construction, covered-reference):** both
|
||||
image-bearing resources reference a single `data.aws_ecr_image.kj_image`;
|
||||
`image_uri = repo@digest`. Verified via CF-1 item 12 (rollback drill)
|
||||
+ CF-2 (operator-attested result row for REQ-371).
|
||||
- **TC-3 (CloudFront OAC pitfall, P4 operator guide):** the guide MUST
|
||||
document the `AuthType: NONE` → OAC-ignored pitfall (RESEARCH §4) as
|
||||
a callout. CF-1 item 9 mechanically verifies it. Already in PLAN P4
|
||||
Wave 0 Task 0.3b; tracked.
|
||||
- **TC-4 (ECR tag format, P1 Wave 1 Task 1.1f):** assert tag matches
|
||||
`^[a-zA-Z0-9._-]+$` before push (D-239). Already in PLAN; tracked.
|
||||
- **TC-5 (import idempotency, covered-reference REQ-361):** CI import
|
||||
treats "Resource already managed by Terraform" as idempotent success
|
||||
(grep the message, not just exit code). Documented in RESEARCH §1;
|
||||
tracked for the ops repo (operator-attested via CF-2).
|
||||
- **TC-6 (Fargate sunset discipline, P4 operator guide):** D-237 —
|
||||
≥30 consecutive days green + architecture review before deletion.
|
||||
Already in PLAN P4 Wave 0 Task 0.3f; tracked.
|
||||
|
||||
---
|
||||
|
||||
## Binding decisions (this grill session)
|
||||
|
||||
| ID | Decision | Rationale | Confidence |
|
||||
|----|----------|-----------|-----------|
|
||||
| **G-1** | The covered-reference pattern is accepted as a verification surface, but ONLY with CF-1 (M1.5 green = hard P6 gate + spike extended to 12 items) + CF-2 (operator-attested result rows for every covered-reference REQ, audited at P6). Without these, it is a deferred-trust assertion (punt) and the grill would REJECT. | User-confirmed: covered-reference is a deferred-trust assertion; M1.5 must be a hard gate; TFM-HITL/IAM-NARROW/JWKS-EDGE-ONLY are unverifiable from acdl absent the extended spike. | 0.78 |
|
||||
| **G-2.1** | M1.5 green (3 consecutive rebuilds of the 12-item spike) is a binding P6 milestone-ship gate, evidenced by an operator-attested row in the operator guide. The P6 audit verifies the row exists + is green. | Dominant silent-failure path = M1.5 never runs green → 14 REQs false-"complete." User-confirmed. | 0.85 |
|
||||
| **G-2.2** | The M1.5 spike is extended 8 → 12 items, adding: JWKS-EDGE-ONLY direct-URL-403 check, IAM-NARROW no-wildcard assertion, TFM-HITL self-approval-rejection check, rollback drill. | INV-18, REQ-360, REQ-357 are otherwise unverifiable from acdl. Rollback is untested (D-236). | 0.80 |
|
||||
| **G-3** | P1 MUST push a `v1.29.0` (or first `v1.29.x`) intermediate tag at P1 ship to produce publish artifacts; P5's "covered-reference: requires v1.29.0 tag" hedge is REMOVED; the smoke test must run against real artifacts or P5 fails closed. | P5's hedge is a second silent-failure path. User-confirmed. | 0.82 |
|
||||
| **G-4** | The `kj` v0.0.3 source-fetch path (repo URL + commit at SHA `4ebb9a19...`) must be confirmed before P1 Wave 1; the confirmed repo URL is recorded as a third line in `platform/abac/kj-version.txt`. If unfetchable → P1 fails closed → escalate. | RESEARCH §7 is silent on the source repo; P1 Wave 1 is built on an open assumption. User-confirmed. | 0.80 |
|
||||
| **G-5** | The covered-reference REQs (355..366, 371) are NOT marked "complete" at P6 unless their operator-guide cutover-gate row is non-empty + green (CF-2). An empty/red row blocks the milestone ship. | Converts "documented" → "evidenced-by-operator-attestation-audited-by-acdl." | 0.78 |
|
||||
|
||||
---
|
||||
|
||||
## Escalations
|
||||
|
||||
None. All 9 axes resolved at confidence ≥ 0.66. The user confirmed the
|
||||
binding verdict (G-1: accepted with 4 conditions). No human escalation
|
||||
required (full autonomy). The kj source-fetch (CF-4) has a fail-closed
|
||||
path: if RESEARCH's open assumption is wrong, P1 fails closed and
|
||||
escalates at that point — but the grill does not pre-escalate a
|
||||
spec dependency the plan already flags.
|
||||
|
||||
---
|
||||
|
||||
## Evidence verified against the live codebase
|
||||
|
||||
- `.github/workflows/publish.yml` line 47-55: trigger is
|
||||
`push: branches: [main]` (P1 Wave 0 changes to `tags: ['v1.29.*']` —
|
||||
matches PLAN).
|
||||
- `.gitea/workflows/publish.yml` exists (P2 removes it — matches PLAN).
|
||||
- `platform/abac/kj-version.txt`: 2 lines (`v0.0.3` + SHA
|
||||
`4ebb9a19...`) — matches PLAN; RESEARCH §7 silent on source repo
|
||||
(CF-4).
|
||||
- `nova/idp/setup.py`: 50 lines, `--check/--apply/--verify/--dry-run`
|
||||
(P3 adds terraform delegation — matches PLAN).
|
||||
- `core/lambda/nova_idp_setup.py` exists (P3 archives its CFN — matches).
|
||||
- `tests/test_idp_auth.py` + `tests/test_kms_roundtrip.py` EXIST (from
|
||||
v1.28); `tests/test_abac_e2e.py` does NOT exist (P1 Wave 3 authors it
|
||||
— matches PLAN).
|
||||
- `pyproject.toml` version = `1.14.0` (P2 bumps to `1.29.0` — matches
|
||||
PLAN; note: v1.28 did not bump it, a v1.28 carry-over the grill
|
||||
flags as minor but does not block on).
|
||||
|
||||
---
|
||||
|
||||
## Grill complete
|
||||
|
||||
The v1.29 plan proceeds with **4 critical fixes** (CF-1 M1.5 hard gate
|
||||
+ spike extension; CF-2 operator-attested result rows; CF-3 P5 live
|
||||
smoke no-hedge; CF-4 kj source confirmation) and **6 tracked
|
||||
conditions**. The covered-reference pattern is accepted as a
|
||||
verification surface **only** because CF-1 + CF-2 convert
|
||||
"documented" into "evidenced-by-operator-attestation-audited-by-acdl-
|
||||
at-P6." Without those fixes, the grill would REJECT: 14 of 17 REQs
|
||||
would ship "complete" on the strength of a markdown pointer to a
|
||||
nonexistent repo's CI.
|
||||
|
||||
Next: apply the 4 critical fixes to PLAN.md + REQUIREMENTS.md, then
|
||||
MVP/UX CHECK → SHIP phase 0.
|
||||
+149
-1
@@ -116,4 +116,152 @@ reason: "No data pipelines / metrics / PowerBI work in v1.28. The metrics layer
|
||||
None. All four active personas span the full milestone. The
|
||||
security-engineer is heaviest in P2 (identity layer) + P3 (threat model);
|
||||
the cli-engineer is heaviest in P1 (CLI substrate); the backend-engineer
|
||||
spans P1 (CodeArtifact/layer) + P2 (Lambdas/DynamoDB).
|
||||
spans P1 (CodeArtifact/layer) + P2 (Lambdas/DynamoDB).
|
||||
|
||||
---
|
||||
|
||||
# Personas — v1.29 Reposplit + Identity Layer Bring-Live
|
||||
|
||||
```yaml
|
||||
project: acdl
|
||||
milestone: v1.29
|
||||
generated_at: 2026-08-20
|
||||
generator: lead-developer
|
||||
verification_toolchain:
|
||||
typecheck: "python3 -m py_compile nova/idp/setup.py core/lambda/nova_idp_setup.py 2>&1 | head -5 || true"
|
||||
test: "pytest tests/test_idp_auth.py tests/test_kms_roundtrip.py -q 2>&1 | tail -15 || true"
|
||||
lint: "ruff check nova/idp/ core/lambda/nova_idp_setup.py 2>/dev/null || true"
|
||||
note: |
|
||||
v1.29 is a feature milestone (Reposplit + Identity Layer Bring-Live).
|
||||
Pure ops/devops focus — Terraform modules are authored out-of-band in
|
||||
nova-platform-ops; CIAgent in acdel delivers publish.yml, Gitea scrub,
|
||||
CFN archive + CLI terraform-delegation, operator guide, consumer bump.
|
||||
Five active personas: backend-engineer (publish.yml ECR image, Lambda
|
||||
zip, GitHub Releases), security-engineer (kj static build verification,
|
||||
KMS round-trip tests, ABAC E2E, M1.5 gate), cli-engineer (nova idp
|
||||
setup --apply terraform delegation, CFN archive), data-engineer
|
||||
(DynamoDB import references, outbox bootstrap docs), lead-developer
|
||||
(plan/review/ship, Gitea scrub, decisions, operator guide, milestone
|
||||
wiring). frontend-engineer deactivated (no UI).
|
||||
```
|
||||
|
||||
## Roster
|
||||
|
||||
### lead-developer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "Milestone plan, persona roster, Gitea scrub (REQ-367), decisions D-232..240 (REQ-368), operator guide (P4), milestone ship, STATE/ROADMAP/PROJECT wiring, covered-reference REQ tracking"
|
||||
frameworks: ["git", "Gitea Actions", "GitHub Actions", "semver tagging", ".ciagent/ discipline", "Terraform (reference only)"]
|
||||
constraints: ["D-232 (forge parity abandoned)", "D-235 (tag-pin handoff)", "D-236 (cutover shape)", "D-238 (KJ-LOCKSTEP)", "OPER-PRIV", "TFM-HITL", "v1.29 hard constraints"]
|
||||
territory:
|
||||
- ".ciagent/**"
|
||||
- "PLAN.md"
|
||||
- "CHECKPOINT.json"
|
||||
- "STATE.md"
|
||||
- "REQUIREMENTS.md"
|
||||
- "ROADMAP.md"
|
||||
- "PROJECT.md"
|
||||
- "CLARIFY.md"
|
||||
- "RESEARCH.md"
|
||||
- "docs/operator-guide-platform-ops.md"
|
||||
- ".github/workflows/ci.yml"
|
||||
- "scripts/sync_workflows.py"
|
||||
- "pyproject.toml"
|
||||
- "README.md"
|
||||
```
|
||||
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "publish.yml ECR container image build (CGO_ENABLED=0 static kj), Lambda zip + layer wheel + Python wheel attach to GitHub Releases, ECR push with tag v1.29.x-kj-<sha>, kj-version.txt read, Dockerfile for lambda:3.12-al2023 base"
|
||||
frameworks: ["Python 3.12", "GitHub Actions", "Docker", "ECR", "Go (CGO_ENABLED=0 build)", "file(1)", "sha256sum"]
|
||||
constraints: ["KJ-STATIC", "D-239 (ECR tag format)", "D-235 (tag-pin handoff)", "REQ-354 criteria 1-4"]
|
||||
territory:
|
||||
- ".github/workflows/publish.yml"
|
||||
- "platform/abac/kj-version.txt"
|
||||
- "core/lambda/nova_idp_token_vend.py"
|
||||
- "core/lambda/nova_idp_auth.py"
|
||||
- "core/lambda/nova_idp_jwks.py"
|
||||
- "tests/test_idp_auth.py"
|
||||
- "tests/test_kms_roundtrip.py"
|
||||
```
|
||||
|
||||
### security-engineer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "kj static-link audit (file(1) asserts statically linked + no shared library), KMS round-trip test against alias/nova-oidc-signing, ABAC E2E (sign-up→sign-in→token-vend→verify, INV-17 fail-closed), M1.5 verification gate tests (8-item spike), KJ-LOCKSTEP digest-equality verification"
|
||||
frameworks: ["KMS Sign/Verify/GetPublicKey", "kyverno-json", "jose", "file(1)", "readelf", "pytest", "moto[dynamodb]"]
|
||||
constraints: ["KJ-STATIC", "KJ-LOCKSTEP", "INV-17 (ABAC fail-closed)", "INV-18 (JWKS-EDGE-ONLY)", "ABAC-FAIL-CLOSED", "ARGON", "KF (KMS asymmetric)"]
|
||||
territory:
|
||||
- "platform/abac/**"
|
||||
- "platform/abac/kj-version.txt"
|
||||
- "adapters/kyverno-json/policies/token-vend.policy"
|
||||
- "tests/test_kms_roundtrip.py"
|
||||
- "tests/test_idp_auth.py"
|
||||
- "tests/test_abac_e2e.py"
|
||||
- "docs/threat-model.md"
|
||||
```
|
||||
|
||||
### cli-engineer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "nova idp setup --apply terraform delegation (REQ-369 AC 2), CFN archive to docs/archive/nova-idp-cfn-v1.28.md (REQ-369 AC 3), which terraform detection + CFN fallback deprecation warning"
|
||||
frameworks: ["Python 3.12", "argparse", "subprocess", "importlib", "shutil.which"]
|
||||
constraints: ["REQ-369", "D-235 (tag-pin handoff)"]
|
||||
territory:
|
||||
- "nova/idp/setup.py"
|
||||
- "core/lambda/nova_idp_setup.py"
|
||||
- "docs/archive/nova-idp-cfn-v1.28.md"
|
||||
- "nova/idp/__init__.py"
|
||||
```
|
||||
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
domain: "DynamoDB table import references (nova-contracts, nova-change-requests, nova-outbox, nova-users, nova-sessions, nova-pats) documented in operator guide, PITR restore procedure, audit outbox bootstrap"
|
||||
frameworks: ["DynamoDB", "AWS CLI (reference)"]
|
||||
constraints: ["REQ-361 (import idempotency, covered-reference)", "JWKS-ROTATION"]
|
||||
territory:
|
||||
- "docs/operator-guide-platform-ops.md"
|
||||
- ".ciagent/ARCHITECTURE.md"
|
||||
reason: |
|
||||
Re-activated for v1.29: the operator guide (P4) documents DynamoDB PITR
|
||||
restore, table imports, and the audit outbox bootstrap — data-engineer
|
||||
owns the data-layer sections of the guide. The Terraform import itself
|
||||
is out-of-band (nova-platform-ops), but the operator-facing docs are
|
||||
in-acdl.
|
||||
```
|
||||
|
||||
### frontend-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: "No UI in v1.29 (pure ops/devops focus). JWKS serves application/json via CloudFront; no HTML/CSS/JS surface."
|
||||
```
|
||||
|
||||
## Territory overlap notes
|
||||
|
||||
- `.github/workflows/publish.yml` (REQ-354) = backend-engineer (ECR
|
||||
image build, Dockerfile, Lambda zip) + lead-developer (Gitea scrub
|
||||
removes the `.gitea/workflows/publish.yml` mirror in P2, D-232).
|
||||
- `nova/idp/setup.py` (REQ-369) = cli-engineer (the `--apply` delegation
|
||||
+ `which terraform` detection) + backend-engineer (the CFN archive
|
||||
content — the CFN template is backend-engineer territory from v1.28).
|
||||
- `platform/abac/kj-version.txt` = security-engineer (KJ-STATIC audit
|
||||
reads + verifies the SHA) + backend-engineer (publish.yml reads the
|
||||
SHA to embed in the ECR tag).
|
||||
- `docs/operator-guide-platform-ops.md` (P4) = lead-developer (cutover
|
||||
gates, cost section, artifact-mirror fallback) + data-engineer (PITR
|
||||
restore, DynamoDB imports) + security-engineer (KMS rotation, JWKS
|
||||
reachability, PAT revocation).
|
||||
|
||||
## Phase-specific personas
|
||||
|
||||
None. All five active personas span the full milestone. The
|
||||
backend-engineer is heaviest in P1 (publish pipeline); the
|
||||
lead-developer is heaviest in P2 (Gitea scrub + decisions) + P4
|
||||
(operator guide) + P6 (final ship); the cli-engineer is heaviest in P3
|
||||
(CFN archive + TF delegation); the security-engineer is heaviest in P1
|
||||
(M1.5 gate tests) + P4 (operator guide security sections); the
|
||||
data-engineer is heaviest in P4 (operator guide data sections).
|
||||
+571
-1
@@ -486,4 +486,574 @@ Optional CloudFront + WAF + ACM (if `--public-jwks-domain`): +~$3/month
|
||||
at pilot volume. ACM is free for CloudFront-attached certs.
|
||||
|
||||
This is a pilot-scale cost envelope. Production scale (100x volume)
|
||||
would still be <$50/month. No hidden costs identified.
|
||||
would still be <$50/month. No hidden costs identified.
|
||||
|
||||
---
|
||||
|
||||
# PLAN — v1.29 Reposplit + Identity Layer Bring-Live
|
||||
|
||||
> **Milestone:** v1.29 (feature — reposplit + identity layer bring-live).
|
||||
> Tags on the **v1.28.x** line: `v1.28.0` (P0) → `v1.28.1..v1.28.5`
|
||||
> (P1..P5) → `v1.28.6` (P6 final = milestone release). The final phase's
|
||||
> patch IS the milestone release.
|
||||
> **Branch:** `milestone/v1.29-reposplit-identity`. Phase branches:
|
||||
> `phase/00-pre-execution` (complete), `phase/01-publish-pipeline`,
|
||||
> `phase/02-gitea-scrub-decisions`, `phase/03-cfn-archive-tf-delegation`,
|
||||
> `phase/04-operator-guide-reference-tracking`,
|
||||
> `nova-blockchain-exchange/phase/05-consumer-deploy-bump` (cross-project),
|
||||
> `phase/06-final-review-ship`.
|
||||
>
|
||||
> **Scope split (CLARIFY-grounded):** Terraform modules authored
|
||||
> out-of-band in `nova-platform-ops`. CIAgent in `acdl` authors only the
|
||||
> acdl-side REQs (354, 367, 368, 369, REQ-OPS-GUIDE, REQ-CONSUMER-BUMP).
|
||||
> Covered-reference REQs (355-366, 371) verified via cutover gates
|
||||
> documented in the operator guide (P4).
|
||||
|
||||
## Milestone goal
|
||||
|
||||
v1.29 makes platform operations a Terraform-controlled discipline that
|
||||
lives outside the engineering repo, with a narrow-IAM `kj` substrate
|
||||
shared by the primary runtime and its defensive fallback. `acdl/acdl`
|
||||
standardizes on GitHub (Gitea scrub); Nova-idp is brought live in
|
||||
account `581513795199` (code complete since v1.28, unverified in-account
|
||||
at Phase 0); `kj` has exactly one identity (one ECR image digest) shared
|
||||
by both substrates (KJ-LOCKSTEP, REQ-371).
|
||||
|
||||
## Requirements
|
||||
|
||||
17 requirements: REQ-354..REQ-369 + REQ-371 + REQ-363b + REQ-OPS-GUIDE
|
||||
+ REQ-CONSUMER-BUMP (full text in `.ciagent/REQUIREMENTS.md` §v1.29).
|
||||
1 invariant: INV-18 (JWKS-EDGE-ONLY). 10 NFR constraints: KJ-STATIC,
|
||||
KJ-LOCKSTEP, KJ-WARMUP-HEALTH, OPER-PRIV, IAM-NARROW, DRIFT-DETECT,
|
||||
IMPORT-IDEMPOTENT, TFM-HITL, JWKS-SLO, JWKS-ROTATION. 9 decisions:
|
||||
D-232..D-238 (CLARIFY) + D-239/D-240 (RESEARCH spec corrections).
|
||||
|
||||
## Phase breakdown
|
||||
|
||||
### Phase P1 — publish-pipeline (REQ-354)
|
||||
|
||||
**Goal:** `publish.yml` attaches Lambda zip + layer wheel + Python wheel
|
||||
+ ECR container image (static `kj`, `CGO_ENABLED=0`, tag
|
||||
`v1.29.x-kj-<sha>`) to GitHub Release for each tag, with matching
|
||||
SHA-256 in the body. The M1.5 verification gate tests
|
||||
(`test_idp_auth`, `test_kms_roundtrip`, ABAC E2E) are authored.
|
||||
|
||||
**Exit criterion:** REQ-354 criteria 1-4 pass; KJ-STATIC audit (file(1)
|
||||
asserts `statically linked`) runs in CI; ECR image pushed with tag
|
||||
`v1.29.x-kj-<sha>` (D-239); GitHub Release body lists image URI + digest
|
||||
alongside wheel + layer + Lambda zip; M1.5 gate tests exist + pass in
|
||||
moto-DDB (live KMS round-trip is covered-reference, runs in
|
||||
nova-platform-ops CI).
|
||||
|
||||
**Branch:** `phase/01-publish-pipeline`. **Tag:** `v1.28.1`.
|
||||
|
||||
#### Wave 0 — publish.yml trigger model (backend-engineer)
|
||||
- **Task 0.1** (backend-engineer): change `.github/workflows/publish.yml`
|
||||
trigger from `push: branches: [main]` to `push: tags: ['v1.29.*']`.
|
||||
Preserve the existing wheel + Lambda layer publish steps (REQ-323/
|
||||
CAP-035). Add the Lambda zip packaging step
|
||||
(`nova-lambda-token-vend-v1.29.x.zip`). Verify the trigger fires on
|
||||
`git tag v1.29.0 && git push --tags`.
|
||||
|
||||
#### Wave 1 — kj source confirmation + static build + ECR image (backend-engineer, security-engineer)
|
||||
- **Task 1.0** (backend-engineer): **kj source-fetch confirmation
|
||||
(grill CF-4/G-4 — binary go/no-go gate before Wave 1).** Confirm the
|
||||
`kj` Go source repo URL + commit at SHA `4ebb9a19...` (read from
|
||||
`platform/abac/kj-version.txt`). Record the repo URL as a 3rd line
|
||||
in `platform/abac/kj-version.txt`. If unfetchable → P1 fails closed
|
||||
→ escalate (this is a spec dependency, not a CIAgent ambiguity). The
|
||||
source repo is the `kyverno-json/kj` Go binary project (distinct
|
||||
from the kyverno-json Python engine adapter in `adapters/kyverno-
|
||||
json/`).
|
||||
- **Task 1.1** (backend-engineer): add a `build-kj-image` job to
|
||||
`publish.yml` that:
|
||||
(a) reads `platform/abac/kj-version.txt` (v0.0.3 + SHA
|
||||
`4ebb9a19...`);
|
||||
(b) fetches the `kj` Go source at the pinned SHA (RESEARCH §7 —
|
||||
source repo confirmed in P1 RESEARCH);
|
||||
(c) builds with `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build
|
||||
-ldflags="-s -w" -o kj ./…`;
|
||||
(d) runs `file kj` and asserts output contains `statically linked`
|
||||
AND does NOT contain `shared library` (KJ-STATIC — fail build
|
||||
otherwise);
|
||||
(e) builds the container image from
|
||||
`public.ecr.aws/lambda/python:3.12-al2023`, copying `kj` to
|
||||
`/opt/kj/kj` with `chmod 0555` owned by `sbx_user:1051`;
|
||||
(f) pushes the image to ECR with tag `v1.29.x-kj-<kj-source-sha>`
|
||||
(D-239 — assert tag matches `^[a-zA-Z0-9._-]+$` before push);
|
||||
(g) records the image URI + digest for the GitHub Release body.
|
||||
- **Task 1.2** (security-engineer): add a KJ-STATIC audit step that
|
||||
runs `file(1)` + `readelf -d kj` (assert no `NEEDED` entries) as a
|
||||
CI gate. If either fails, the publish job fails closed. This is the
|
||||
mechanical enforcement of KJ-STATIC (not just a human review).
|
||||
|
||||
#### Wave 2 — GitHub Release body + SHA-256 (backend-engineer)
|
||||
- **Task 2.1** (backend-engineer): extend the `publish.yml` release step
|
||||
to attach: (a) `nova-lambda-token-vend-v1.29.x.zip`; (b)
|
||||
`nova-cli-layer-v1.29.x.zip`; (c) `nova-1.29.x-py3-none-any.whl`; (d)
|
||||
the ECR image URI + digest. Compute SHA-256 for each artifact + list
|
||||
in the release body. Verify REQ-354 criteria 1, 2, 4 (artifacts
|
||||
appear, independent per tag, image URI + digest listed).
|
||||
|
||||
#### Wave 3 — M1.5 verification gate tests (security-engineer)
|
||||
- **Task 3.1** (security-engineer): author `tests/test_idp_auth.py` —
|
||||
sign-up → sign-in → session flow against moto-DDB (covers Edge 5
|
||||
item 5). Skip live-KMS assertions (covered-reference — runs in
|
||||
nova-platform-ops CI).
|
||||
- **Task 3.2** (security-engineer): author
|
||||
`tests/test_kms_roundtrip.py` — sign/verify round-trip against
|
||||
`alias/nova-oidc-signing`. Mark as `@pytest.mark.live_aws` (skipped in
|
||||
acdl CI; runs in nova-platform-ops CI against the live key, REQ-362).
|
||||
- **Task 3.3** (security-engineer): author
|
||||
`tests/test_abac_e2e.py` — known PAT → ABAC-allowed action → signed
|
||||
OIDC token → `jose` verification → green; known PAT + ABAC-denied
|
||||
action → 403 with deny reason logged (INV-17 fail-closed, Edge 5
|
||||
item 7). Uses moto-DDB + mock KMS.
|
||||
|
||||
#### Wave 4 — regression + ship (lead-developer)
|
||||
- **Task 4.1** (lead-developer): run full test suite; verify 1000+
|
||||
tests still pass (no regressions from publish.yml changes). Verify
|
||||
CAP-001..038 regression gate green. **Push a `v1.29.0` intermediate
|
||||
tag at P1 ship** (grill CF-3/G-3) to trigger `publish.yml` + produce
|
||||
the v1.29 artifacts (Lambda zip + layer wheel + Python wheel + ECR
|
||||
image). The milestone release tag remains `v1.28.6`; the `v1.29.0`
|
||||
artifact tag is a P1 intermediate to produce publish artifacts for
|
||||
P5's smoke test. Ship P1 → `v1.28.1`.
|
||||
|
||||
### Phase P2 — gitea-scrub-decisions (REQ-367, REQ-368)
|
||||
|
||||
**Goal:** Hard scrub of all Gitea references in `acdl/acdl`; `.gitea/`
|
||||
removed; `forge_parity_disabled` CI assertion; pyproject → 1.29.0;
|
||||
decisions D-232..238 recorded in PROJECT.md + CLARIFY (already done in
|
||||
P0; this phase adds the CI assertion + the actual file scrub).
|
||||
|
||||
**Exit criterion:** `grep -rni gitea .github/ docs/ pyproject.toml
|
||||
README.md .ciagent/` returns zero matches outside the spec archive
|
||||
section; `find .gitea` returns nothing; CI `forge_parity_disabled`
|
||||
assertion passes; pyproject.toml version = 1.29.0.
|
||||
|
||||
**Branch:** `phase/02-gitea-scrub-decisions`. **Tag:** `v1.28.2`.
|
||||
|
||||
#### Wave 0 — pyproject bump (lead-developer)
|
||||
- **Task 0.1** (lead-developer): bump `pyproject.toml` version →
|
||||
`1.29.0` (spec §7.2). Verify `nova --version` reports `1.29.0`.
|
||||
|
||||
#### Wave 1 — .gitea/ removal (lead-developer)
|
||||
- **Task 1.1** (lead-developer): `rm -rf .gitea/` (7 workflow files +
|
||||
README.md, RESEARCH §9d). Remove `scripts/sync_workflows.py` (the
|
||||
byte-identical-forges generator — central removal target, D-232).
|
||||
Remove Gitea references from `scripts/sync_to_nova.sh` (line 201:
|
||||
`--exclude=/.gitea`) + `scripts/rotate_spike_key.sh` (Gitea API
|
||||
secret upload). Scrub `terraform/bootstrap/` Gitea OIDC references
|
||||
(the OIDC role for act_runner moves to nova-platform-ops; the
|
||||
bootstrap here becomes archived reference).
|
||||
|
||||
#### Wave 2 — Gitea reference scrub (lead-developer)
|
||||
- **Task 2.1** (lead-developer): `grep -rni gitea .github/ docs/
|
||||
pyproject.toml README.md .ciagent/` — scrub all matches outside the
|
||||
spec archive section (`.ciagent/REQUIREMENTS.md` §v1.29 + CLARIFY §v1.29
|
||||
+ RESEARCH §v1.29 retain "Gitea" as historical/reference text; these
|
||||
are the "spec archive section" exemption per REQ-367 AC 1). Update
|
||||
`.github/workflows/ci.yml` to remove any Gitea-specific steps.
|
||||
|
||||
#### Wave 3 — forge_parity_disabled CI assertion (lead-developer)
|
||||
- **Task 3.1** (lead-developer): add a CI step to `.github/workflows/ci.yml`
|
||||
that asserts `forge_parity_disabled` — the step runs
|
||||
`test ! -d .gitea/` and `! grep -rqi gitea .github/workflows/` and
|
||||
exits 0 on success, non-zero with `forge_parity_disabled` message on
|
||||
failure (REQ-367 AC 3, D-232). This is the deliberate CI failure that
|
||||
documents the abandoned parity.
|
||||
|
||||
#### Wave 4 — decisions verification + ship (lead-developer)
|
||||
- **Task 4.1** (lead-developer): verify D-232..238 + D-239/240 are
|
||||
present in PROJECT.md + CLARIFY.md + REQUIREMENTS.md (REQ-368 AC 1-2,
|
||||
already authored in P0; this task is a verification, not re-authoring).
|
||||
Run full test suite; ship P2 → `v1.28.2`.
|
||||
|
||||
### Phase P3 — cfn-archive-tf-delegation (REQ-369)
|
||||
|
||||
**Goal:** Archive the CFN template in `nova/idp/setup.py` +
|
||||
`core/lambda/nova_idp_setup.py` to `docs/archive/nova-idp-cfn-v1.28.md`
|
||||
(read-only reference); `nova idp setup --apply` delegates to `terraform
|
||||
apply` (the CLI detects terraform via `which terraform`; if absent,
|
||||
falls back to the CFN path with a deprecation warning).
|
||||
|
||||
**Exit criterion:** `docs/archive/nova-idp-cfn-v1.28.md` exists +
|
||||
contains the CFN template as read-only reference; `nova idp setup
|
||||
--apply` invokes `terraform apply` when terraform is on PATH (tested
|
||||
with a mock terraform binary); the CFN path emits a deprecation warning
|
||||
when terraform is absent.
|
||||
|
||||
**Branch:** `phase/03-cfn-archive-tf-delegation`. **Tag:** `v1.28.3`.
|
||||
|
||||
#### Wave 0 — CFN archive (cli-engineer, backend-engineer)
|
||||
- **Task 0.1** (backend-engineer): extract the CFN template from
|
||||
`core/lambda/nova_idp_setup.py` + write it to
|
||||
`docs/archive/nova-idp-cfn-v1.28.md` as a fenced code block with a
|
||||
read-only header ("Archived at v1.29.0 — the active path is
|
||||
`terraform apply` in `nova-platform-ops`. Deletion is a follow-up
|
||||
after Terraform parity is verified.").
|
||||
- **Task 0.2** (cli-engineer): mark the CFN generation code path in
|
||||
`core/lambda/nova_idp_setup.py` as deprecated (add a
|
||||
`DeprecationWarning` when the CFN path is invoked + a docstring
|
||||
pointing to the archive + the terraform delegation path).
|
||||
|
||||
#### Wave 1 — terraform delegation (cli-engineer)
|
||||
- **Task 1.1** (cli-engineer): modify `nova/idp/setup.py` `--apply` to
|
||||
detect terraform via `shutil.which("terraform")`. If terraform is on
|
||||
PATH: delegate to `subprocess.run(["terraform", "apply",
|
||||
"-auto-approve"])` in the `nova-platform-ops` checkout (the operator
|
||||
runs this from the ops repo root). If terraform is absent: fall back
|
||||
to the CFN path with a `DeprecationWarning` ("CFN path is archived;
|
||||
install terraform or use nova-platform-ops. See
|
||||
docs/archive/nova-idp-cfn-v1.28.md.").
|
||||
- **Task 1.2** (cli-engineer): add `nova idp setup --verify` delegation
|
||||
to `terraform plan` (same `which terraform` detection). The verify
|
||||
path runs `terraform plan` + reports the diff.
|
||||
|
||||
#### Wave 2 — tests (cli-engineer)
|
||||
- **Task 2.1** (cli-engineer): author
|
||||
`tests/test_idp_setup_tf_delegation.py` — test the `--apply` path
|
||||
with a mock terraform binary on PATH (assert `subprocess.run` called
|
||||
with `["terraform", "apply", "-auto-approve"]`); test the fallback
|
||||
path with terraform absent (assert `DeprecationWarning` raised + CFN
|
||||
path invoked); test `--verify` delegates to `terraform plan`.
|
||||
|
||||
#### Wave 3 — ship (lead-developer)
|
||||
- **Task 3.1** (lead-developer): run full test suite; ship P3 →
|
||||
`v1.28.3`.
|
||||
|
||||
### Phase P4 — operator-guide-reference-tracking (REQ-OPS-GUIDE)
|
||||
|
||||
**Goal:** `docs/operator-guide-platform-ops.md` covering KMS rotation,
|
||||
JWKS reachability via CloudFront edge, PITR restore, PAT revocation,
|
||||
edge configuration, Fargate standby health, cost section, artifact-
|
||||
mirror fallback, and the M1/M1.5/M2 cutover gates as release-gate
|
||||
entries for the covered-reference REQs. ARCHITECTURE.md §12.9. STATE.md
|
||||
v1.29 CAPs + invariants. REQUIREMENTS.md covered-reference markers.
|
||||
|
||||
**Exit criterion:** operator guide exists + covers all sections per
|
||||
REQ-OPS-GUIDE AC; ARCHITECTURE.md §12.9 added; STATE.md updated with
|
||||
v1.29 rows; covered-reference REQs in REQUIREMENTS.md marked with their
|
||||
cutover gate.
|
||||
|
||||
**Branch:** `phase/04-operator-guide-reference-tracking`. **Tag:**
|
||||
`v1.28.4`.
|
||||
|
||||
#### Wave 0 — operator guide (lead-developer, data-engineer, security-engineer)
|
||||
- **Task 0.1** (lead-developer): author
|
||||
`docs/operator-guide-platform-ops.md` sections: (a) Overview + the
|
||||
reposplit rationale (Vision §4); (b) Day-0 cutover procedure (M1
|
||||
steps from spec §3.2 Journey 2); (c) M1.5 verification gate (8-item
|
||||
spike, 3 consecutive rebuilds); (d) M2 operational handoff loop
|
||||
(tag-pin bump → plan → HITL approval → apply); (e) M2a Fargate
|
||||
activation (conditional on M1.5 failure); (f) Rollback procedure
|
||||
(D-236 — revert `nova_platform_version` pin); (g) cost section (WAF
|
||||
~$5-10/month + Fargate ~$15-20/month, REQ-363b AC 4); (h) artifact-
|
||||
mirror fallback (operator-local mirror by SHA-256 when Gitea
|
||||
act_runner cannot reach GitHub Releases, Edge 6).
|
||||
- **Task 0.2** (data-engineer): author the operator guide data
|
||||
sections: (a) DynamoDB PITR restore procedure (per-table); (b)
|
||||
DynamoDB import addresses (nova-contracts, nova-change-requests,
|
||||
nova-outbox, nova-users, nova-sessions, nova-pats — the
|
||||
`importable-resources.tf` map, REQ-361 covered-reference); (c) audit
|
||||
outbox bootstrap; (d) JWKS-ROTATION (24-hour overlap window on key
|
||||
rotation).
|
||||
- **Task 0.3** (security-engineer): author the operator guide security
|
||||
sections: (a) KMS rotation (90-day cadence, `alias/nova-oidc-
|
||||
signing`, `ECC_NIST_P256`, D-234); (b) JWKS reachability via
|
||||
CloudFront edge (OAC pinning, `AuthType: AWS_IAM`, direct Function
|
||||
URL → 403, INV-18); (c) PAT revocation (60s SLO, D-229); (d) edge
|
||||
configuration (CloudFront + WAF + ACM + Route53 — REQ-364/365/366
|
||||
covered-reference); (e) Fargate standby health checks (`GET /health`
|
||||
every 10s, `KJ-WARMUP-HEALTH`, 3 consecutive probe failures → alert +
|
||||
token-vend fails closed, REQ-363b AC 2); (f) Fargate sunset
|
||||
discipline (D-237 — ≥30 consecutive days green before deletion +
|
||||
architecture review); (g) IAM scope (IAM-NARROW, REQ-360 covered-
|
||||
reference — no `Action: "*"` or `Resource: "*"`); (h) the
|
||||
`route53_record_not_resolvable` debugging path (ACM cert status
|
||||
check).
|
||||
|
||||
#### Wave 1 — covered-reference cutover gates (lead-developer)
|
||||
- **Task 1.1** (lead-developer): add a "Cutover Gates" section to the
|
||||
operator guide listing each covered-reference REQ (355, 356, 357,
|
||||
358, 359, 360, 361, 362, 363, 363b, 364, 365, 366, 371) with its
|
||||
gate entry (M1/M1.5/M2) + the verification command + a **"Result"
|
||||
column** (grill CF-2/G-5). P6 audit verifies every covered-reference
|
||||
REQ has a non-empty, green Result. Empty/red → P6 blocks. The Result
|
||||
column is populated by the operator attestation (the operator runs
|
||||
the verification command in `nova-platform-ops` CI + records the
|
||||
outcome). This is the acdl-side evidence surface for covered-
|
||||
reference REQs.
|
||||
- **Task 1.2** (lead-developer): update REQUIREMENTS.md §v1.29 traceability
|
||||
table — mark each covered-reference REQ with its cutover gate in the
|
||||
Status column (e.g., `planned (M1 gate: nova-platform-ops)`).
|
||||
|
||||
#### Wave 2 — ARCHITECTURE.md + STATE.md (lead-developer)
|
||||
- **Task 2.1** (lead-developer): add ARCHITECTURE.md §12.9 (Platform
|
||||
Ops Reposplit) — the domain boundary (engineering ends at the
|
||||
compiled artifact; operations begins at the live platform under
|
||||
guardrails), the `kj` substrate (one ECR image digest, KJ-LOCKSTEP),
|
||||
the covered-reference REQ tracking pattern, the operator guide
|
||||
pointer.
|
||||
- **Task 2.2** (lead-developer): update STATE.md — append v1.29
|
||||
capability rows (CAP-039: platform-ops-reposplit, CAP-040:
|
||||
kj-substrate-lockstep, CAP-041: jwks-edge-only) + bump invariants
|
||||
(INV-18 JWKS-EDGE-ONLY + the 10 NFR constraints). Bump "Last
|
||||
milestone ship" to v1.29 (pending).
|
||||
|
||||
#### Wave 3 — ship (lead-developer)
|
||||
- **Task 3.1** (lead-developer): run full test suite; ship P4 →
|
||||
`v1.28.4`.
|
||||
|
||||
### Phase P5 — consumer-deploy-bump (REQ-CONSUMER-BUMP, cross-project)
|
||||
|
||||
**Goal:** Bump `nova-blockchain-exchange` deploy.yml `@v1.25` → `@v1.29`
|
||||
in both `.github/workflows/deploy.yml` + `.gitea/workflows/deploy.yml`
|
||||
+ smoke test (sign-up → sign-in → token-vend → apply → audit against
|
||||
v1.29 publish artifacts).
|
||||
|
||||
**Exit criterion:** both deploy.yml files reference `@v1.29`; smoke
|
||||
test passes (the chain completes against v1.29 publish artifacts).
|
||||
|
||||
**Branch:** `nova-blockchain-exchange/phase/05-consumer-deploy-bump`
|
||||
(cross-project, multi-project branch naming per branch-strategy.md).
|
||||
**Tag:** `v1.28.5`.
|
||||
|
||||
#### Wave 0 — deploy.yml bump (lead-developer)
|
||||
- **Task 0.1** (lead-developer): in the `nova-blockchain-exchange`
|
||||
project, update `.github/workflows/deploy.yml` + `.gitea/workflows/
|
||||
deploy.yml` `uses:` ref from `acdl/.github/workflows/deploy.yml@v1.25`
|
||||
→ `@v1.29` (RESEARCH §9e — the consumer's `.gitea/` is out of scope
|
||||
for the acdl REQ-367 scrub; the consumer may keep its Gitea mirror or
|
||||
follow suit — this is a consumer-repo decision, not an acdl one).
|
||||
|
||||
#### Wave 1 — smoke test (lead-developer, security-engineer)
|
||||
- **Task 1.1** (security-engineer): author
|
||||
`nova-blockchain-exchange/tests/test_v1.29_smoke.py` — sign-up →
|
||||
sign-in → token-vend → apply → audit chain against the v1.29 publish
|
||||
artifacts (the consumer's contract → `deploy.yml@v1.29` mode=full →
|
||||
apply → attest → record against `581513795199`). Uses the existing
|
||||
CAP-025 round-trip assertion (v1.26).
|
||||
- **Task 1.2** (lead-developer): run the smoke test; verify the chain
|
||||
completes against the real v1.29.0 publish artifacts (produced by
|
||||
P1's intermediate tag, grill CF-3/G-3). **No hedge** — the smoke
|
||||
test MUST run against the published v1.29.x artifacts or P5 fails
|
||||
closed. If the artifacts are not available (P1 did not push the
|
||||
intermediate tag), P5 blocks until P1 re-ships.
|
||||
|
||||
#### Wave 2 — ship (lead-developer)
|
||||
- **Task 2.1** (lead-developer): ship P5 → `v1.28.5`. The consumer
|
||||
project ships independently (merge to the consumer's main, not
|
||||
acdl's milestone branch).
|
||||
|
||||
### Phase P6 — final-review-ship (Final Phase)
|
||||
|
||||
**Goal:** Multi-persona code review across P1..P5; audit (reconstruction
|
||||
test, branch hygiene, commit discipline, file discipline); milestone
|
||||
ship (merge `phase/06` → `milestone/v1.29-reposplit-identity` → `main`;
|
||||
tag `v1.28.6` = the v1.29 release; Gitea release; delete all milestone
|
||||
branches); mark all v1.29 REQs complete in REQUIREMENTS.md + ROADMAP.md.
|
||||
|
||||
**Exit criterion:** review P0 issues auto-fixed, P1+ flagged; audit
|
||||
PASS; milestone merged to main; tag `v1.28.6` created; Gitea release
|
||||
published; milestone branches deleted; REQUIREMENTS.md + ROADMAP.md
|
||||
marked complete.
|
||||
|
||||
**Branch:** `phase/06-final-review-ship`. **Tag:** `v1.28.6` =
|
||||
milestone release.
|
||||
|
||||
#### Wave 0 — review (lead-developer)
|
||||
- **Task 0.1** (lead-developer): delegate to `ciagent-review` —
|
||||
multi-persona review (lead-developer, backend-engineer, security-
|
||||
engineer, data-engineer, cli-engineer) across P1..P5. Auto-apply P0
|
||||
fixes; flag P1+ for post-hoc review. If P1+ issues found: fix them
|
||||
in this phase.
|
||||
|
||||
#### Wave 1 — audit (lead-developer)
|
||||
- **Task 1.1** (lead-developer): delegate to `ciagent-audit` —
|
||||
reconstruction test (git log ↔ `.ciagent/`), branch hygiene, commit
|
||||
discipline, file discipline. If critical issues found: fix them in
|
||||
this phase.
|
||||
|
||||
#### Wave 2 — milestone ship (lead-developer)
|
||||
- **Task 2.1** (lead-developer): delegate to `ciagent-ship` — merge
|
||||
`phase/06` → `milestone/v1.29-reposplit-identity` → `main`; tag
|
||||
`v1.28.6`; Gitea release with full milestone summary; delete all
|
||||
milestone branches (phase/00..06 + milestone/v1.29-reposplit-
|
||||
identity).
|
||||
|
||||
#### Wave 3 — milestone completion (lead-developer)
|
||||
- **Task 3.1** (lead-developer): update REQUIREMENTS.md (all v1.29 REQs
|
||||
→ complete), ROADMAP.md (v1.29 → complete), NORTH_STAR.md (note
|
||||
Strategic Objective — platform operations as a Terraform-controlled
|
||||
discipline), STATE.md (bump "Last milestone ship" to v1.29, tag
|
||||
`v1.28.6`). Commit `docs(milestone): complete v1.29-reposplit-
|
||||
identity`.
|
||||
|
||||
---
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
1. **CLI flag:** `nova idp setup --apply` now delegates to `terraform
|
||||
apply` (REQ-369 AC 2) — the operator runs this from the
|
||||
`nova-platform-ops` checkout. `nova idp setup --verify` delegates to
|
||||
`terraform plan`.
|
||||
2. **GitHub Release artifacts page:** each `v1.29.x` tag's GitHub
|
||||
Release page lists the Lambda zip + layer wheel + Python wheel + ECR
|
||||
image URI/digest with SHA-256 (REQ-354) — this is the engineering-
|
||||
to-ops handoff surface (D-235 tag-pin handoff).
|
||||
3. **Operator guide:** `docs/operator-guide-platform-ops.md` — the
|
||||
operator-facing runbook covering KMS rotation, JWKS reachability,
|
||||
PITR restore, PAT revocation, edge config, Fargate standby, cost,
|
||||
artifact-mirror fallback, and the M1/M1.5/M2 cutover gates.
|
||||
4. **CI assertion:** `forge_parity_disabled` — the deliberate CI
|
||||
failure documenting the abandoned byte-identical-forges parity
|
||||
(D-232, REQ-367 AC 3).
|
||||
|
||||
## Happy Path
|
||||
|
||||
**M1.5 verification gate (spec §3.3 Edge 5, 12-item spike — written
|
||||
BEFORE execute, extended per grill CF-1):**
|
||||
|
||||
1. `kj` v0.0.3 (pinned SHA in `platform/abac/kj-version.txt`) compiles
|
||||
with `CGO_ENABLED=0 GOOS=linux GOARCH=amd64`.
|
||||
2. Resulting binary reports `file kj → ELF 64-bit LSB executable,
|
||||
x86-64, statically linked, no shared library` (KJ-STATIC).
|
||||
3. Container image built from
|
||||
`public.ecr.aws/lambda/python:3.12-al2023` with the binary copied
|
||||
to `/opt/kj/kj`, `chmod 0555`, owned by `sbx_user:1051`.
|
||||
4. Lambda runtime `python3.12` executes
|
||||
`nova_idp_token_vend.handler`; the handler invokes
|
||||
`subprocess.run(['/opt/kj/kj', 'apply', ...])` and parses stdout
|
||||
JSON.
|
||||
5. `tests/test_idp_auth.py` passes against the live image in moto-DDB.
|
||||
6. `tests/test_kms_roundtrip.py` passes against the live KMS key
|
||||
(REQ-362 path — covered-reference, runs in nova-platform-ops CI).
|
||||
7. End-to-end: known PAT → known ABAC-allowed action → signed OIDC
|
||||
token → `jose` verification → green. Known PAT + ABAC-denied action
|
||||
→ 403 with deny reason logged (INV-17).
|
||||
8. Image URI is recorded in Terraform state and in the operator guide.
|
||||
9. **(grill CF-1)** Direct JWKS Function URL → 403 / via-CloudFront →
|
||||
200 (INV-18, JWKS-EDGE-ONLY — `AuthType: AWS_IAM` verified, not
|
||||
prose).
|
||||
10. **(grill CF-1)** IAM-NARROW: no `Action: "*"` or `Resource: "*"`
|
||||
in the Gitea OIDC role effective permissions (REQ-360).
|
||||
11. **(grill CF-1)** TFM-HITL: self-approval rejected —
|
||||
`gitea.triggering_actor == pull_request.user.login` → apply fails
|
||||
closed (REQ-357, INV-3).
|
||||
12. **(grill CF-1)** Rollback drill — revert `nova_platform_version`
|
||||
pin → prior digest runs (D-236 cutover shape + rollback procedure).
|
||||
|
||||
If items 1-7 fail three consecutive rebuilds, M2a activates REQ-363b
|
||||
(Fargate toggle) with the same image — no warmup hit because the
|
||||
standby is always running the same digest.
|
||||
|
||||
**HARD P6 SHIP GATE (grill CF-1/G-2.1):** P6 must not ship `v1.28.6`
|
||||
until the operator guide contains an operator-attested "M1.5
|
||||
Verification Gate Result" row (3 consecutive green rebuilds, run
|
||||
IDs/SHAs, attestor identity). P6 audit verifies the row exists. The
|
||||
M1.5 gate is verified in `nova-platform-ops` CI (out-of-band); the
|
||||
operator attestation in the guide is the acdl-side evidence surface.
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
1. **M1 acceptance gate (spec §2.3):** `terraform apply` from `main`
|
||||
brings the live AWS account to a state where Nova-idp identity
|
||||
tables exist, JWT-issuing paths are wired but not yet consuming
|
||||
container images, JWKS infrastructure is in place, WAF + OAC pinning
|
||||
the CloudFront edge; `acdl/acdl v1.29.0` ships with zero `.gitea/`
|
||||
references and zero platform-infra files; D-232..238 recorded in
|
||||
PROJECT.md/CLARIFY.
|
||||
2. **M1.5 acceptance gate:** items 1-12 of the Edge 5 spike all green
|
||||
on three consecutive rebuilds; image digest resolvable via
|
||||
`data.aws_ecr_image.kj_image`; sign/verify round-trip passes; ABAC
|
||||
fail-closed path verified against live policy; JWKS-EDGE-ONLY
|
||||
verified (item 9); IAM-NARROW verified (item 10); TFM-HITL
|
||||
self-approval rejected (item 11); rollback drill passes (item 12).
|
||||
**HARD P6 ship gate** — operator-attested "M1.5 Verification Gate
|
||||
Result" row in the operator guide (grill CF-1/G-2.1).
|
||||
3. **M2 acceptance gate:** Bumping `local.nova_platform_version` in a
|
||||
PR and merging it results in `terraform apply` updating both
|
||||
`aws_lambda_function.nova_idp_token_vend.image_uri` and
|
||||
`aws_ecs_task_definition.kj.container_definitions[0].image` to the
|
||||
same digest (KJ-LOCKSTEP, REQ-371), with zero diff on KMS, DDB,
|
||||
IAM, edge.
|
||||
|
||||
## Test evidence required for v1.29 release
|
||||
|
||||
- [ ] Code coverage ≥ 80% on new modules (the acdl-side files:
|
||||
`publish.yml` changes, `nova/idp/setup.py` terraform delegation,
|
||||
`docs/operator-guide-platform-ops.md` is docs — no coverage
|
||||
requirement; the M1.5 gate tests).
|
||||
- [ ] CI/CD pipeline GREEN for `acdl/acdl` (the `nova-platform-ops`
|
||||
pipeline is out-of-band).
|
||||
- [ ] M1.5 verification gate green: items 1-12 of §3.3 Edge 5 spike
|
||||
pass on three consecutive rebuilds (covered-reference — verified
|
||||
in nova-platform-ops CI; acdl authors the tests in P1; operator
|
||||
attests in the guide, P4; P6 audit verifies the attestation row,
|
||||
grill CF-1/G-2.1).
|
||||
- [ ] Covered-reference REQs (355-366, 371) have non-empty, green
|
||||
Result in the operator guide "Cutover Gates" section (grill
|
||||
CF-2/G-5 — P6 audit verifies).
|
||||
- [ ] `lifecycle.precondition` enforced on both image-bearing resources
|
||||
(REQ-371 mechanical proof — covered-reference in
|
||||
nova-platform-ops).
|
||||
- [ ] Live KMS sign/verify round-trip verified in account
|
||||
`581513795199` (covered-reference).
|
||||
- [ ] Live ABAC sign/verify round-trip verified against the production
|
||||
policy (covered-reference).
|
||||
- [ ] Pilot consumer (`nova-blockchain-exchange`) smoke test green:
|
||||
sign-up → sign-in → token-vend → apply → audit chain (P5).
|
||||
- [ ] All existing capabilities (CAP-001..038) still pass the
|
||||
regression gate.
|
||||
- [ ] Drift-detection baseline: `terraform plan` exit 0 against live
|
||||
AWS state, captured at cutover (covered-reference).
|
||||
- [ ] `kj` standby Fargate task health `READY` before M1 cutover
|
||||
(covered-reference, KJ-WARMUP-HEALTH).
|
||||
- [ ] Fargate standby sunset discipline documented in operator-guide
|
||||
(D-237, P4).
|
||||
- [ ] `forge_parity_disabled` CI assertion passes (P2, REQ-367 AC 3).
|
||||
- [ ] `grep -rni gitea .github/ docs/ pyproject.toml README.md
|
||||
.ciagent/` returns zero matches outside the spec archive section
|
||||
(P2, REQ-367 AC 1).
|
||||
|
||||
## Plan completeness checklist
|
||||
|
||||
- [x] Every REQ mapped to a phase + wave + task.
|
||||
- [x] Covered-reference REQs identified + their verification surface
|
||||
documented (operator guide P4, cutover gates).
|
||||
- [x] Decisions D-232..240 referenced in the plan.
|
||||
- [x] Invariants + NFR constraints referenced (KJ-STATIC, KJ-LOCKSTEP,
|
||||
INV-18, etc.).
|
||||
- [x] Personas assigned to every task (lead-developer, backend-engineer,
|
||||
security-engineer, cli-engineer, data-engineer).
|
||||
- [x] User-Facing Surface section (3 surfaces named).
|
||||
- [x] Happy Path section (M1.5 8-item spike, written before execute).
|
||||
- [x] UX Acceptance Criteria section (M1, M1.5, M2 gates).
|
||||
- [x] Test evidence checklist.
|
||||
- [x] Phase boundaries + tags (v1.28.0 → v1.28.6).
|
||||
- [x] Cross-project phase (P5, nova-blockchain-exchange) identified.
|
||||
|
||||
## Cost envelope (v1.29)
|
||||
|
||||
Monthly estimate for the `nova-platform-ops` live platform (documented
|
||||
in the operator guide, P4):
|
||||
|
||||
| Resource | Quantity | Est. monthly |
|
||||
|----------|----------|-------------|
|
||||
| WAF WebACL (CloudFront-scoped) | 1 | ~$5-10/month (+ per-request) |
|
||||
| Fargate standby (0.25 vCPU, 512 MB) | 1 task | ~$15-20/month (REQ-363b AC 4) |
|
||||
| KMS asymmetric key | 1 | ~$1/month |
|
||||
| DynamoDB (on-demand, 7 tables) | 7 | ~$2/month (pilot volume) |
|
||||
| DynamoDB PITR | 7 tables | ~$2/month |
|
||||
| Lambda invocations (3 Lambdas) | 3 | ~$2/month |
|
||||
| ECR image storage | ~100 MB | <$1/month |
|
||||
| S3 state bucket + access logs | 1 | <$1/month |
|
||||
| CloudFront + ACM + Route53 | 1 distribution | ~$1/month (ACM free) |
|
||||
| **Total** | | **~$30-40/month** |
|
||||
|
||||
This is the pilot-scale ops cost envelope. The Fargate standby
|
||||
(~$15-20/month) is the largest line item + is explicitly documented in
|
||||
the operator guide (REQ-363b AC 4) with the D-237 sunset discipline
|
||||
(≥30 consecutive days green before deletion + architecture review).
|
||||
+134
-9
@@ -450,7 +450,7 @@ already exist).
|
||||
> Review: 0 P0. Audit: reconstruction PASS, file/branch/commit discipline CLEAN.
|
||||
> Full phase detail: `.ciagent/archive/` (v1.27 artifacts) + git history.
|
||||
|
||||
## v1.28 — CLI Canonicalization + Identity Layer (active)
|
||||
## v1.28 — CLI Canonicalization + Identity Layer (complete, tag `v1.27.6`, merged to main 2026-08-19)
|
||||
|
||||
> **Feature milestone — active.** The Nova CLI becomes installable from
|
||||
> internal PyPI (CodeArtifact), every `core/` module is reachable as a
|
||||
@@ -528,14 +528,139 @@ New requirements REQ-323..REQ-353 — full text in
|
||||
- `nova idp setup --apply` MUST present the CloudFormation template for
|
||||
review before any resource is created (NFR-10).
|
||||
|
||||
### v1.28 phase status (active — phase 0 in progress)
|
||||
### v1.28 phase status (complete — tag `v1.27.6` = the v1.28 release)
|
||||
|
||||
- **P0** pre-execution → `v1.27.0` (complete).
|
||||
- **P1..P5** execution phases → `v1.27.1..v1.27.5` (complete).
|
||||
- **P6** final review + audit + milestone ship → `v1.27.6` = the v1.28
|
||||
release (complete, merged to main 2026-08-19).
|
||||
|
||||
> Phase-by-phase task breakdown, wave ordering, and persona assignments:
|
||||
> `.ciagent/PLAN.md` (retained). Authoritative resume state:
|
||||
> `.ciagent/CHECKPOINT.json`.
|
||||
|
||||
---
|
||||
|
||||
## v1.29 — Reposplit + Identity Layer Bring-Live (active, milestone branch `milestone/v1.29-reposplit-identity`)
|
||||
|
||||
> **Feature milestone — active.** v1.29 extracts all live platform
|
||||
> components (Nova-idp Lambdas, KMS keys, DynamoDB tables, S3 state
|
||||
> buckets, OIDC roles, JWKS, audit outbox bootstrap) from `acdl/acdl`
|
||||
> into a dedicated Gitea-private Terraform repository
|
||||
> (`nova-platform-ops`), brings Nova-idp live in account `581513795199`
|
||||
> for the first time (code complete since v1.28, unverified-in-account at
|
||||
> Phase 0), and standardizes `acdl/acdl` on GitHub. The split enforces
|
||||
> Vision §4 domain boundaries architecturally: engineering ends at the
|
||||
> compiled artifact; operations begins at the live platform under
|
||||
> guardrails. Vision §5 "Narrow capability interfaces" shapes the
|
||||
> substrate design — `kj` has exactly one identity (one ECR image
|
||||
> digest), shared by both the production runtime and its defensive
|
||||
> fallback, eliminating drift by construction (KJ-LOCKSTEP).
|
||||
|
||||
### Scope split (CLARIFY-grounded, full autonomy)
|
||||
|
||||
The spec creates a **separate** Gitea-private repo `nova-platform-ops`.
|
||||
CIAgent runs inside `acdl`. The Terraform module code
|
||||
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`) is
|
||||
authored **out-of-band** in `nova-platform-ops` (operator-owned). CIAgent
|
||||
in `acdl` delivers only the acdl-side work and tracks the ops-side REQs
|
||||
as **covered-reference** (verification surface = the M1/M1.5/M2 cutover
|
||||
gates documented in the operator guide, not a missing test).
|
||||
|
||||
| In-acdl (CIAgent authors) | Covered-reference (nova-platform-ops) |
|
||||
|---|---|
|
||||
| REQ-354 (publish.yml + ECR image + Release) | REQ-355, 356, 357, 358 (ops CI/HITL/pin) |
|
||||
| REQ-367 (Gitea scrub) | REQ-359 (Gitea-private repo) |
|
||||
| REQ-368 (decisions D-232..238) | REQ-360 (IAM scope bounded) |
|
||||
| REQ-369 (CFN archive + CLI `--apply` TF delegation) | REQ-361 (import idempotency) |
|
||||
| Operator guide `docs/operator-guide-platform-ops.md` | REQ-362 (KMS key provisioning) |
|
||||
| `platform/abac/kj-version.txt` | REQ-363, 363b (Lambda/Fargate substrate) |
|
||||
| M1.5 verification gate tests | REQ-364, 365, 366 (JWKS/WAF/ACM edge) |
|
||||
| nova-blockchain-exchange deploy.yml @v1.29 bump | REQ-371 `lifecycle.precondition` (TF-side) |
|
||||
|
||||
### v1.29 ID allocations (no collisions with shipped history)
|
||||
|
||||
- **Requirements:** `REQ-354..REQ-369` + `REQ-371` + `REQ-363b` (note:
|
||||
REQ-370 is intentionally unused per the source spec). Max existing REQ
|
||||
= REQ-353. REQ-363b is a sub-requirement of REQ-363 (Fargate defensive
|
||||
fallback, same ECR image — KJ-LOCKSTEP).
|
||||
- **Decisions:** `D-232..D-238` (7 decisions, authored in CLARIFY) +
|
||||
`D-239..D-240` (2 research-derived spec corrections). Max existing D
|
||||
= D-231.
|
||||
- **Invariants:** `INV-18` (JWKS-EDGE-ONLY — proposed in spec §5, promoted
|
||||
here). Plus non-invariant NFRs carried as constraints: KJ-STATIC,
|
||||
KJ-LOCKSTEP, KJ-WARMUP-HEALTH, OPER-PRIV, IAM-NARROW, DRIFT-DETECT,
|
||||
IMPORT-IDEMPOTENT, TFM-HITL, JWKS-SLO, JWKS-ROTATION. Max existing INV
|
||||
= INV-17.
|
||||
- **`kj` here is the Go binary** (`platform/abac/kj-version.txt`, pinned
|
||||
v0.0.3), NOT the kyverno-json engine. v1.28 re-mapped the spec's `kj`
|
||||
engine → kyverno-json (D-227). v1.29 reintroduces `kj` as a **compiled
|
||||
Go binary** embedded in the ECR container image — a distinct artifact.
|
||||
No collision: kyverno-json remains the policy engine (INV-4); `kj` is a
|
||||
static binary invoked via `subprocess` by the Lambda handler.
|
||||
|
||||
### v1.29 Requirements
|
||||
|
||||
New requirements REQ-354..REQ-369 + REQ-371 + REQ-363b — full text in
|
||||
`.ciagent/REQUIREMENTS.md` §v1.29. Summary by phase:
|
||||
|
||||
- **P1 — Publish Pipeline (REQ-354):** `publish.yml` attaches Lambda zip
|
||||
+ layer wheel + Python wheel + ECR container image (static `kj`,
|
||||
`CGO_ENABLED=0`, tag `v1.29.x+kj-<sha>`) to GitHub Release with SHA-256.
|
||||
- **P2 — Gitea Scrub + Decisions (REQ-367, REQ-368):** remove `.gitea/`,
|
||||
scrub all Gitea refs, `forge_parity_disabled` CI assertion, pyproject
|
||||
→ 1.29.0, record D-232..238.
|
||||
- **P3 — CFN Archive + TF Delegation (REQ-369):** archive CFN template →
|
||||
`docs/archive/nova-idp-cfn-v1.28.md`, `nova idp setup --apply` delegates
|
||||
to `terraform apply`.
|
||||
- **P4 — Operator Guide + Reference Tracking:** `docs/operator-guide-
|
||||
platform-ops.md`, ARCHITECTURE.md §12.9, STATE.md v1.29 CAPs +
|
||||
invariants; REQUIREMENTS.md covered-reference markers.
|
||||
- **P5 — Consumer Deploy Bump (cross-project, Edge 8):** `nova-
|
||||
blockchain-exchange` deploy.yml `@v1.25` → `@v1.29` + smoke test.
|
||||
- **P6 — Final Review + Audit + Milestone Ship.**
|
||||
|
||||
### v1.29 Hard constraints
|
||||
|
||||
- DO NOT activate pilot qa/prod/dr environments (D-208/D-209 — separate
|
||||
initiative). M1 brings Nova-idp live; env activation is out.
|
||||
- DO NOT add S3 Object Lock / JWS tamper-resistance (D-083). Tamper-
|
||||
evidence via SQLite hash-chain remains.
|
||||
- DO NOT restore 73.8% coverage — separate NFR milestone; YELLOW carried
|
||||
without scope expansion.
|
||||
- DO NOT provision CodeArtifact — direct GitHub Releases artifact fetch.
|
||||
- DO NOT delete the CFN template in `acdl/acdl` at v1.29.0 — archive as
|
||||
read-only reference (`docs/archive/nova-idp-cfn-v1.28.md`); deletion is
|
||||
a follow-up after Terraform parity is verified.
|
||||
- DO NOT add Nova-idp feature work (new OIDC claims, new ABAC rules) —
|
||||
bring live; don't extend.
|
||||
- DO NOT add MFA/TOTP, WebAuthn, upstream IdP federation (Vision §7).
|
||||
- DO NOT add a CloudFront Frontend (L3B consumer surface) — pure ops
|
||||
focus only.
|
||||
- The `kj` binary MUST be compiled `CGO_ENABLED=0` and verified statically
|
||||
linked (`file(1)`) before embedding (KJ-STATIC).
|
||||
- The ECR image digest on the Fargate standby MUST equal the Lambda
|
||||
`image_uri` digest at every `terraform plan` (KJ-LOCKSTEP, REQ-371 —
|
||||
fail-closed by `lifecycle.precondition` mechanism, not by discipline).
|
||||
- The JWKS endpoint is the ONLY public read surface; all other platform
|
||||
endpoints gate with `AuthType: AWS_IAM` (JWKS-EDGE-ONLY, INV-18).
|
||||
- Any `terraform apply` against `main` in `nova-platform-ops` MUST require
|
||||
a Gitea Actions approval from a user distinct from the PR author
|
||||
(TFM-HITL, INV-3 applied at platform level).
|
||||
- `nova-platform-ops` MUST be `private: true` in Gitea, not mirrored
|
||||
(OPER-PRIV).
|
||||
|
||||
### v1.29 phase status (active — phase 0 in progress)
|
||||
|
||||
- **P0** pre-execution (SPECIFY→CLARIFY→RESEARCH→PLAN→GRILL→MVP/UX) — in
|
||||
progress, target tag `v1.27.0`.
|
||||
- **P1..PN** execution phases — planned in PLAN.md.
|
||||
- **P(N+1)** final review + audit + milestone ship — target tag
|
||||
`v1.27.(N+1)` = the v1.28 release.
|
||||
progress, target tag `v1.28.0`.
|
||||
- **P1..P5** execution phases — planned in PLAN.md.
|
||||
- **P6** final review + audit + milestone ship — target tag
|
||||
`v1.28.6` = the v1.29 release.
|
||||
|
||||
> Phase-by-phase task breakdown, wave ordering, and persona assignments
|
||||
> will live in `.ciagent/PLAN.md`. Authoritative resume state:
|
||||
> `.ciagent/CHECKPOINT.json`.
|
||||
> Tags run on the **v1.28.x** line: `v1.28.0` (P0) →
|
||||
> `v1.28.1..v1.28.5` (execution phases) → `v1.28.6` (final phase =
|
||||
> milestone release). Milestone branch:
|
||||
> `milestone/v1.29-reposplit-identity`. Phase-by-phase task breakdown,
|
||||
> wave ordering, and persona assignments will live in `.ciagent/PLAN.md`.
|
||||
> Authoritative resume state: `.ciagent/CHECKPOINT.json`.
|
||||
+278
-32
@@ -301,7 +301,7 @@ Full v1.26 requirement text:
|
||||
`.ciagent/nova-blockchain-exchange/REQUIREMENTS.md`. Active phase plan:
|
||||
`.ciagent/PLAN.md`.
|
||||
|
||||
## v1.28 — CLI Canonicalization + Identity Layer (active)
|
||||
## v1.28 — CLI Canonicalization + Identity Layer (complete, tag `v1.27.6`, merged to main 2026-08-19)
|
||||
|
||||
> **Feature milestone — active.** The Nova CLI is installable from
|
||||
> internal PyPI (CodeArtifact); every `core/` module is reachable as a
|
||||
@@ -571,34 +571,280 @@ All v1.28 release-gate criteria in PLAN.md §6 met.
|
||||
|
||||
| REQ | Phase | Status |
|
||||
|-----|-------|--------|
|
||||
| REQ-323 | P1 | planned |
|
||||
| REQ-324 | P1 | planned |
|
||||
| REQ-325 | P1 | planned |
|
||||
| REQ-326 | P1 | planned |
|
||||
| REQ-327 | P1 | planned |
|
||||
| REQ-328 | P1 | planned |
|
||||
| REQ-329 | P2 | planned |
|
||||
| REQ-330 | P2 | planned |
|
||||
| REQ-331 | P2 | planned |
|
||||
| REQ-332 | P2 | planned |
|
||||
| REQ-333 | P3 | planned |
|
||||
| REQ-334 | P3 | planned |
|
||||
| REQ-335 | P3 | planned |
|
||||
| REQ-336 | P4 | planned |
|
||||
| REQ-337 | P4 | planned |
|
||||
| REQ-338 | P4 | planned |
|
||||
| REQ-339 | P4 | planned |
|
||||
| REQ-340 | P4 | planned |
|
||||
| REQ-341 | P4 | planned |
|
||||
| REQ-342 | P4 | planned |
|
||||
| REQ-343 | P4 | planned |
|
||||
| REQ-344 | P4 | planned |
|
||||
| REQ-345 | P5 | planned |
|
||||
| REQ-346 | P5 | planned |
|
||||
| REQ-347 | P5 | planned |
|
||||
| REQ-348 | P5 | planned |
|
||||
| REQ-349 | P5 | planned |
|
||||
| REQ-350 | P5 | planned |
|
||||
| REQ-351 | P5 | planned |
|
||||
| REQ-352 | P6 | planned |
|
||||
| REQ-353 | P6 | planned |
|
||||
| REQ-323 | P1 | complete (v1.27.1) |
|
||||
| REQ-324 | P1 | complete (v1.27.1) |
|
||||
| REQ-325 | P1 | complete (v1.27.1) |
|
||||
| REQ-326 | P1 | complete (v1.27.1) |
|
||||
| REQ-327 | P1 | complete (v1.27.1) |
|
||||
| REQ-328 | P1 | complete (v1.27.1) |
|
||||
| REQ-329 | P2 | complete (v1.27.2) |
|
||||
| REQ-330 | P2 | complete (v1.27.2) |
|
||||
| REQ-331 | P2 | complete (v1.27.2) |
|
||||
| REQ-332 | P2 | complete (v1.27.2) |
|
||||
| REQ-333 | P3 | complete (v1.27.3) |
|
||||
| REQ-334 | P3 | complete (v1.27.3) |
|
||||
| REQ-335 | P3 | complete (v1.27.3) |
|
||||
| REQ-336 | P4 | complete (v1.27.4) |
|
||||
| REQ-337 | P4 | complete (v1.27.4) |
|
||||
| REQ-338 | P4 | complete (v1.27.4) |
|
||||
| REQ-339 | P4 | complete (v1.27.4) |
|
||||
| REQ-340 | P4 | complete (v1.27.4) |
|
||||
| REQ-341 | P4 | complete (v1.27.4) |
|
||||
| REQ-342 | P4 | complete (v1.27.4) |
|
||||
| REQ-343 | P4 | complete (v1.27.4) |
|
||||
| REQ-344 | P4 | complete (v1.27.4) |
|
||||
| REQ-345 | P5 | complete (v1.27.5) |
|
||||
| REQ-346 | P5 | complete (v1.27.5) |
|
||||
| REQ-347 | P5 | complete (v1.27.5) |
|
||||
| REQ-348 | P5 | complete (v1.27.5) |
|
||||
| REQ-349 | P5 | complete (v1.27.5) |
|
||||
| REQ-350 | P5 | complete (v1.27.5) |
|
||||
| REQ-351 | P5 | complete (v1.27.5) |
|
||||
| REQ-352 | P6 | complete (v1.27.6) |
|
||||
| REQ-353 | P6 | complete (v1.27.6) |
|
||||
|
||||
---
|
||||
|
||||
## v1.29 — Reposplit + Identity Layer Bring-Live (active, milestone branch `milestone/v1.29-reposplit-identity`)
|
||||
|
||||
> **Feature milestone — active.** v1.29 extracts all live platform
|
||||
> components into a dedicated Gitea-private Terraform repository
|
||||
> (`nova-platform-ops`), brings Nova-idp live in account `581513795199`
|
||||
> for the first time, and standardizes `acdl/acdl` on GitHub. `kj` (a
|
||||
> compiled Go binary, pinned v0.0.3, distinct from the kyverno-json
|
||||
> engine) has exactly one identity: one ECR image digest shared by the
|
||||
> production Lambda runtime and its defensive Fargate fallback
|
||||
> (KJ-LOCKSTEP, REQ-371).
|
||||
>
|
||||
> Tags run on the **v1.28.x** line: `v1.28.0` (P0) →
|
||||
> `v1.28.1..v1.28.5` (execution) → `v1.28.6` (final = milestone release).
|
||||
> Milestone branch: `milestone/v1.29-reposplit-identity`.
|
||||
>
|
||||
> **Scope split (CLARIFY-grounded, full autonomy):** Terraform module
|
||||
> code is authored out-of-band in `nova-platform-ops`. REQs marked
|
||||
> `[covered-reference]` have their verification surface in the
|
||||
> `nova-platform-ops` cutover gates (M1/M1.5/M2), documented in the
|
||||
> operator guide (`docs/operator-guide-platform-ops.md`). CIAgent in
|
||||
> `acdl` authors only the acdl-side REQs.
|
||||
|
||||
### Decisions (locked in CLARIFY — full autonomy, load-bearing for v1.29)
|
||||
|
||||
- **D-232 (Forge parity abandoned):** the byte-identical-forges CI parity
|
||||
(Gitea + GitHub) is abandoned; `acdl/acdl` standardizes on GitHub. CI
|
||||
fails with `forge_parity_disabled` (deliberate). Rationale: Vision §4
|
||||
domain boundaries — operations lives in Gitea-private `nova-platform-
|
||||
ops`, engineering lives on GitHub.
|
||||
- **D-233 (JWKS public-read via CloudFront edge):** the JWKS endpoint is
|
||||
the only public read surface of the live platform (INV-18). All other
|
||||
platform endpoints gate with `AuthType: AWS_IAM`. CloudFront + OAC
|
||||
pinning replaces direct Lambda Function URL exposure.
|
||||
- **D-234 (KMS asymmetric key provisioning):** `alias/nova-oidc-signing`
|
||||
provisioned with `KeySpec: ECC_NIST_P256`, `KeyUsage: SIGN_VERIFY`,
|
||||
90-day rotation cadence (matches per-stack CMK rotation per D-069).
|
||||
- **D-235 (Tag-pin handoff):** engineering hands off to operations via
|
||||
tags. `acdl/acdl` `publish.yml` attaches artifacts to GitHub Releases
|
||||
per tag; `nova-platform-ops` declares `local.nova_platform_version` +
|
||||
`local.kj_source_sha` and resolves substrates through a single
|
||||
`data.aws_ecr_image.kj_image`.
|
||||
- **D-236 (Cutover shape + rollback procedure):** M1 day-0 cutover is
|
||||
conditional on M1.5 verification gate (3 consecutive rebuilds, 12-item
|
||||
spike per grill CF-1). Rollback = revert `nova_platform_version` pin;
|
||||
the prior tag's artifacts remain downloadable. M2a (Fargate toggle)
|
||||
activates only if M1.5 fails 3×.
|
||||
- **D-237 (Fargate sunset discipline):** the always-warm minimal Fargate
|
||||
standby (REQ-363b, ~$15–20/month) may not be deleted unless REQ-363 has
|
||||
been green in production for ≥30 consecutive days. Sunset requires an
|
||||
architecture review.
|
||||
- **D-238 (KJ-LOCKSTEP release-gate invariant):** the ECR image digest
|
||||
running on the Fargate standby MUST equal the digest resolved by
|
||||
`aws_lambda_function.nova_idp_token_vend.image_uri` at every
|
||||
`terraform plan`. Enforced by `lifecycle.precondition` (mechanism) +
|
||||
Gitea Actions `if: steps.plan.outcome == 'success'` (mechanism) + PR
|
||||
comment reporting (observability) + operator review (last, never
|
||||
first). No second pipeline, no second SHA pin. Vision §6 immutability
|
||||
+ Vision §5 narrow interfaces.
|
||||
|
||||
### P1 — Publish Pipeline
|
||||
|
||||
#### REQ-354 — `publish.yml` attaches Lambda zip + layer wheel + Python wheel + ECR container image to GitHub Release for each tag
|
||||
**Journeys:** J1, J2 (criteria 3–4). **Priority:** High.
|
||||
**AC:**
|
||||
**(1)** Given a tag `v1.29.x` is pushed to `acdl/acdl` main, when
|
||||
`publish.yml` runs, then the release artifacts `nova-lambda-token-vend-
|
||||
v1.29.x.zip`, `nova-cli-layer-v1.29.x.zip`, and `nova-1.29.x-py3-none-
|
||||
any.whl` appear in GitHub Releases with matching SHA-256 in the body.
|
||||
**(2)** Given two consecutive tags `v1.29.0` and `v1.29.1`, when both
|
||||
releases are queried, then each tag's artifacts are independent and the
|
||||
previous tag's artifacts remain downloadable.
|
||||
**(3)** Given the publish pipeline runs for tag `v1.29.x`, when the
|
||||
image build step executes, then a single ECR image is pushed at tag
|
||||
`v1.29.x-kj-<kj-source-sha>` where `<kj-source-sha>` is read from
|
||||
`platform/abac/kj-version.txt` at build time and embedded in the tag
|
||||
(D-239: ECR tags reject `+`; corrected from `v1.29.x+kj-<sha>` to
|
||||
`v1.29.x-kj-<sha>`).
|
||||
**(4)** Given the image is pushed, when the GitHub Release body lists
|
||||
artifacts, then the image URI and digest appear alongside the wheel,
|
||||
layer, and Lambda zip. KJ-STATIC: the `kj` binary is compiled
|
||||
`CGO_ENABLED=0 GOOS=linux GOARCH=amd64` and `file(1)` reports
|
||||
`statically linked, no shared library` before embedding.
|
||||
|
||||
### P2 — Gitea Scrub + Decisions
|
||||
|
||||
#### REQ-367 — Hard scrub of all Gitea references in `acdl/acdl` at v1.29.0
|
||||
**Journeys:** Cross-cutting. **Priority:** Critical.
|
||||
**AC:**
|
||||
**(1)** Given v1.29.0 is cut from main, when `grep -rni gitea .github/
|
||||
docs/ pyproject.toml README.md .ciagent/` runs, then zero matches
|
||||
outside this spec's archive section.
|
||||
**(2)** Given v1.29.0 ships, when `.gitea/` is checked in the working
|
||||
tree, then `find .gitea` returns nothing.
|
||||
**(3)** Given v1.29.0 ships, when the bit-identical-forges parity is
|
||||
asserted in CI, then CI fails with `forge_parity_disabled` (deliberate;
|
||||
documented in D-232).
|
||||
|
||||
#### REQ-368 — Decisions D-232..238 recorded in PROJECT.md + CLARIFY
|
||||
**Journeys:** Cross-cutting. **Priority:** High.
|
||||
**AC:**
|
||||
**(1)** Given the milestone is recorded, when loading `PROJECT.md`, then
|
||||
decisions D-232 (forge parity abandoned), D-233 (JWKS public-read via
|
||||
CloudFront edge), D-234 (KMS asymmetric key provisioning), D-235 (tag-
|
||||
pin handoff), D-236 (cutover shape + rollback procedure), D-237
|
||||
(Fargate sunset discipline ≥30 days → architecture review), D-238
|
||||
(KJ-LOCKSTEP release-gate invariant) are present with rationale citing
|
||||
Vision §4 domain boundaries.
|
||||
**(2)** Given decisions are present, then each decision references the
|
||||
source statement from the v1.29 spec.
|
||||
|
||||
### P3 — CFN Archive + TF Delegation
|
||||
|
||||
#### REQ-369 — CFN → Terraform conversion of `nova idp setup`
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:**
|
||||
**(1)** Given the CFN template in `acdl/acdl/nova/idp/setup.py`, when
|
||||
the equivalent Terraform in `nova-platform-ops` runs, then the same
|
||||
resources (Lambdas, DDB tables, IAM roles, KMS key references) are
|
||||
created. [covered-reference: nova-platform-ops]
|
||||
**(2)** Given the conversion, when a new operator runs `nova idp setup
|
||||
--apply`, then the CLI delegates to `terraform apply`; the CFN code
|
||||
path is no longer the active path.
|
||||
**(3)** Given the conversion, the CFN file in `acdl/acdl` is archived
|
||||
to `docs/archive/nova-idp-cfn-v1.28.md` as read-only reference;
|
||||
deletion is a follow-up.
|
||||
|
||||
### P4 — Operator Guide + Reference Tracking (docs)
|
||||
|
||||
#### REQ-OPS-GUIDE — `docs/operator-guide-platform-ops.md`
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given the operator guide is published, when an operator reads
|
||||
it, then it covers: KMS rotation (90-day cadence, `alias/nova-oidc-
|
||||
signing`), JWKS reachability via CloudFront edge (OAC pinning, public
|
||||
read vs. IAM-gated), PITR restore (DynamoDB point-in-time recovery),
|
||||
PAT revocation (60s SLO), edge configuration (CloudFront + WAF + ACM +
|
||||
Route53), Fargate standby status checks (`GET /health` every 10s,
|
||||
`KJ-WARMUP-HEALTH`), cost section (WAF ~$5–10/month + Fargate
|
||||
~$15–20/month), artifact-mirror fallback (operator-local mirror by
|
||||
SHA-256 when Gitea `act_runner` cannot reach GitHub Releases), and the
|
||||
M1/M1.5/M2 cutover gates as release-gate entries for the covered-
|
||||
reference REQs.
|
||||
|
||||
### P5 — Consumer Deploy Bump (cross-project, Edge 8)
|
||||
|
||||
#### REQ-CONSUMER-BUMP — `nova-blockchain-exchange` deploy.yml `@v1.25` → `@v1.29`
|
||||
**Journeys:** J1. **Priority:** High.
|
||||
**AC:**
|
||||
**(1)** Given `nova-blockchain-exchange` deploy.yml pins
|
||||
`acdl/.github/workflows/deploy.yml@v1.25`, when the bump is applied,
|
||||
then both `.github/workflows/deploy.yml` and
|
||||
`.gitea/workflows/deploy.yml` reference `@v1.29`.
|
||||
**(2)** Given the bump, when the smoke test runs (sign-up → sign-in →
|
||||
token-vend → apply → audit), then the chain completes successfully
|
||||
against the v1.29 publish artifacts.
|
||||
|
||||
### Covered-reference requirements (authored in `nova-platform-ops`, out-of-band)
|
||||
|
||||
The following REQs are tracked for milestone completeness but their
|
||||
code lands in `nova-platform-ops`. Their verification surface is the
|
||||
M1/M1.5/M2 cutover gates documented in the operator guide.
|
||||
|
||||
- **REQ-355** — ops repo pins `local.nova_platform_version` +
|
||||
`local.kj_source_sha`; CI resolves matching artifacts + image digest.
|
||||
- **REQ-356** — ops repo CI runs `terraform plan` on every PR; drift
|
||||
fails with `drift_detected`.
|
||||
- **REQ-357** — HITL approver distinct from PR author required for
|
||||
`terraform apply` (INV-3, TFM-HITL).
|
||||
- **REQ-358** — Operator bumps `nova_platform_version` to roll out
|
||||
engineering change; `CodeSha256` matches the artifact SHA-256.
|
||||
- **REQ-359** — ops repo is Gitea-private with no GitHub mirror
|
||||
(OPER-PRIV).
|
||||
- **REQ-360** — ops repo IAM scope is bounded; no AdministratorAccess
|
||||
(IAM-NARROW).
|
||||
- **REQ-361** — Terraform imports existing live resources idempotently
|
||||
(IMPORT-IDEMPOTENT).
|
||||
- **REQ-362** — `alias/nova-oidc-signing` KMS key provisioned
|
||||
(`ECC_NIST_P256`, `SIGN_VERIFY`, 90-day rotation).
|
||||
- **REQ-363** — Nova-idp 3 Lambdas deployed on container image with
|
||||
static `kj` (production substrate, KJ-STATIC).
|
||||
- **REQ-363b** — Fargate defensive fallback — always-warm minimal
|
||||
Fargate standby, **same ECR image** (KJ-LOCKSTEP, KJ-WARMUP-HEALTH).
|
||||
- **REQ-364** — JWKS Function URL reachable only via CloudFront with
|
||||
OAC pinning (INV-18, JWKS-EDGE-ONLY).
|
||||
- **REQ-365** — WAF WebACL rate-limit (3000/5min) + AWS Managed Rules.
|
||||
- **REQ-366** — ACM cert + Route53 alias for the JWKS domain.
|
||||
- **REQ-371** — KJ-LOCKSTEP applied-at-plan mechanism
|
||||
(`lifecycle.precondition` on both image-bearing resources; fail-closed
|
||||
by mechanism, not by discipline).
|
||||
|
||||
### v1.29 Invariants + NFR constraints (new)
|
||||
|
||||
- **INV-18 (JWKS-EDGE-ONLY):** the JWKS endpoint is the only public read
|
||||
surface of the live platform. All other platform endpoints MUST gate
|
||||
with `AuthType: AWS_IAM`.
|
||||
- **KJ-STATIC (NFR):** `kj` compiled `CGO_ENABLED=0`; `file(1)` reports
|
||||
`statically linked, no shared library`; SHA-256 matches
|
||||
`platform/abac/kj-version.txt`; recorded in Terraform state.
|
||||
- **KJ-LOCKSTEP (NFR):** Fargate standby digest == Lambda `image_uri`
|
||||
digest at every `terraform plan`. Detected by
|
||||
`lifecycle.precondition` (mechanism) + CI `if:
|
||||
steps.plan.outcome == 'success'` (mechanism) + PR comment
|
||||
(observability) + operator review (last). No second pipeline, no
|
||||
second SHA pin.
|
||||
- **KJ-WARMUP-HEALTH (NFR):** Fargate standby `READY` probe (`GET /health
|
||||
→ 200` every 10s) green before M1 cutover; release-gate entry.
|
||||
- **OPER-PRIV (NFR):** `nova-platform-ops` `private: true`, not mirrored.
|
||||
- **IAM-NARROW (NFR):** Gitea OIDC role bounded per REQ-360; no
|
||||
`Action: "*"` or `Resource: "*"`.
|
||||
- **DRIFT-DETECT (NFR):** `terraform plan` exit 2 (drift) fails the
|
||||
apply workflow; manual reconciliation required.
|
||||
- **IMPORT-IDEMPOTENT (NFR):** re-import exits non-zero with
|
||||
`resource_already_imported`.
|
||||
- **TFM-HITL (NFR):** `terraform apply` against `main` requires Gitea
|
||||
Actions approval from a user distinct from the PR author.
|
||||
- **JWKS-SLO (NFR):** `GET /.well-known/jwks.json` P95 < 200ms same-
|
||||
region; `Cache-Control: max-age=3600` honored.
|
||||
- **JWKS-ROTATION (NFR):** on key rotation, both old + new public keys
|
||||
published during 24-hour overlap window.
|
||||
|
||||
### v1.29 Traceability (live — see CHECKPOINT.json for authoritative state)
|
||||
|
||||
| REQ | Phase | Status |
|
||||
|-----|-------|--------|
|
||||
| REQ-354 | P1 | planned |
|
||||
| REQ-367 | P2 | planned |
|
||||
| REQ-368 | P2 | planned |
|
||||
| REQ-369 | P3 | planned |
|
||||
| REQ-OPS-GUIDE | P4 | planned |
|
||||
| REQ-CONSUMER-BUMP | P5 | planned |
|
||||
| REQ-355 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-356 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-357 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-358 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-359 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-360 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-361 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-362 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-363 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-363b | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-364 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-365 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-366 | covered-reference | planned (nova-platform-ops) |
|
||||
| REQ-371 | covered-reference | planned (nova-platform-ops) |
|
||||
+255
-1
@@ -333,4 +333,258 @@ greenfield files: `nova/` CLI package, `platform/abac/token-vend.policy`,
|
||||
All 11 research questions answered with cited findings + concrete
|
||||
recommendations + risks. D-228 amended (fail-closed, not pure-Python
|
||||
fallback). The `kj` binary packaging is the highest-risk item (P2
|
||||
spike). Next: PLAN.
|
||||
spike). Next: PLAN.
|
||||
|
||||
---
|
||||
|
||||
# Nova — v1.29 Research Findings
|
||||
|
||||
> Phase: research (pre-execution). Milestone: v1.29 (Reposplit + Identity
|
||||
> Layer Bring-Live). Status: research. Researcher: ci-researcher.
|
||||
> Autonomy: full.
|
||||
>
|
||||
> Research delegated to the ci-researcher subagent (10 topics — Terraform
|
||||
> import idempotency, `data.aws_ecr_image` digest resolution,
|
||||
> `lifecycle.precondition`, CloudFront OAC for Lambda Function URL, WAF
|
||||
> on CloudFront, ACM DNS validation + Route53 alias, `kj` Go binary
|
||||
> static build, ECR tag format, codebase inspection, Gitea Actions HITL).
|
||||
> This file is the curated summary. Key findings + recommendations below.
|
||||
|
||||
---
|
||||
|
||||
## §1 — Terraform `import` idempotency (REQ-361)
|
||||
|
||||
- `terraform import <addr> <id>` reads an existing cloud resource into
|
||||
state without modifying it; the resource must have a matching
|
||||
`resource` block in config.
|
||||
- Re-importing an address already in state fails with **`Error: Resource
|
||||
already managed by Terraform`** (non-zero exit). The CI import step
|
||||
must treat this specific error as idempotent success (grep the
|
||||
message, not just exit code) — this is the IMPORT-IDEMPOTENT contract.
|
||||
- `importable-resources.tf` is a convention (not built-in): a dedicated
|
||||
file listing resource addresses imported from the live account (S3
|
||||
state bucket, DynamoDB tables, IAM OIDC role, KMS keys) so the import
|
||||
surface is enumerable + reviewable.
|
||||
- Drift detection: `terraform plan -detailed-exitcode` (exit 2 = drift)
|
||||
fails the apply; the state bucket is bootstrapped manually then
|
||||
imported (never created by Terraform — avoids bootstrapping the
|
||||
bootstrapper, Q1/§7.1, D-235).
|
||||
|
||||
**Recommendation:** `nova-platform-ops` maintains an
|
||||
`importable-resources.tf` map; CI import treats "already managed" as
|
||||
idempotent success; `plan -detailed-exitcode` asserts zero drift.
|
||||
|
||||
## §2 — `data.aws_ecr_image` digest resolution (REQ-355, REQ-371)
|
||||
|
||||
- `data "aws_ecr_image" "kj_image" { repository_name = …; image_tag = … }`
|
||||
resolves the tag to an **immutable `sha256:` digest** via
|
||||
`image_digest`.
|
||||
- ECR tags are mutable by default (a re-push moves a tag → different
|
||||
digest). KJ-LOCKSTEP pins on `image_digest`, never the tag.
|
||||
- `image_uri` = `${data.aws_ecr_repository.kj.repository_url}@${data.aws_ecr_image.kj_image.image_digest}`
|
||||
— pinning by `@digest`, not `:tag`. Both Lambda and Fargate reference
|
||||
the same data source → same digest by construction.
|
||||
- `data.aws_ecr_image` reads at plan time; if the tag doesn't exist
|
||||
(engineering hasn't published), the data source fails the plan (Q7
|
||||
fail-closed).
|
||||
|
||||
**Recommendation:** Both image-bearing resources reference a single
|
||||
`data.aws_ecr_image.kj_image`; `image_uri` = `repo@digest`; LOCKSTEP is
|
||||
true by construction + the precondition (§3) is a verification.
|
||||
|
||||
## §3 — `lifecycle.precondition` — the KJ-LOCKSTEP mechanism (REQ-371)
|
||||
|
||||
- **Version correction (D-240):** preconditions introduced in
|
||||
**Terraform v1.2.0 (May 2022)**, NOT v1.4+ as the spec implies. The
|
||||
ops repo `required_version = ">= 1.2.0"` suffices.
|
||||
- Syntax: `precondition` block inside `lifecycle { … }` for resources.
|
||||
Evaluated **before** the resource action (during planning); a failing
|
||||
precondition aborts the **plan** with the custom `error_message`.
|
||||
- `error_message` is a string expression — can interpolate values:
|
||||
`error_message = "KJ-LOCKSTEP: Fargate='${aws_ecs_task_definition.kj.image}' canonical='${data.aws_ecr_image.kj_image.image_digest}'"`.
|
||||
- Asserting two attributes resolve to the same value:
|
||||
```hcl
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = self.image_uri == "${data.aws_ecr_repository.kj.repository_url}@${data.aws_ecr_image.kj_image.image_digest}"
|
||||
error_message = "KJ-LOCKSTEP: Lambda image does not match the resolved ECR digest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pitfalls:** precondition blocks cannot reference `count`/`for_each`
|
||||
unexpanded resources; both resources must depend on the same data source
|
||||
(explicit `depends_on` if `image_uri` is computed indirectly).
|
||||
|
||||
**Recommendation:** Add `lifecycle { precondition { … } }` to **both**
|
||||
the Lambda and Fargate task; set `required_version = ">= 1.2.0"`.
|
||||
|
||||
## §4 — CloudFront OAC pinning to Lambda Function URL (D-233, REQ-364)
|
||||
|
||||
- **Critical:** CloudFront OAC for a Lambda Function URL origin requires
|
||||
`AuthType: AWS_IAM` on the Function URL (NOT `AuthType: NONE`). With
|
||||
`AWS_IAM`, direct access returns 403 unless SigV4-signed; CloudFront +
|
||||
OAC signs requests on the viewer's behalf → CloudFront 200, direct 403
|
||||
(INV-18 JWKS-EDGE-ONLY).
|
||||
- OAC resource: `OriginAccessControlOriginType = "lambda"`,
|
||||
`SigningBehavior = "always"`, `SigningProtocol = "sigv4"`. Attach via
|
||||
`OriginAccessControlId` on the origin block; HTTPS only.
|
||||
- Resource-based permission: `aws lambda add-permission --action
|
||||
lambda:InvokeFunctionUrl --principal cloudfront.amazonaws.com
|
||||
--source-arn <distribution ARN>` — binds the Function URL to the
|
||||
specific distribution.
|
||||
- OAC replaces the deprecated S3-origin OAI; for Lambda origins, OAC is
|
||||
the only signing mechanism.
|
||||
|
||||
**Pitfall:** if `AuthType: NONE` is left on the Function URL, OAC signing
|
||||
is ignored and the URL stays public — the 403 guarantee evaporates.
|
||||
|
||||
**Recommendation:** JWKS Function URL `authorization_type = "AWS_IAM"`,
|
||||
`lambda`-type OAC (`SigningBehavior: always`), `lambda:InvokeFunctionUrl`
|
||||
permission scoped to the distribution ARN.
|
||||
|
||||
## §5 — WAF WebACL rate-limit + AWS Managed Rules on CloudFront (REQ-365)
|
||||
|
||||
- Rate-based rule: `RateBasedStatement` with `Limit: 3000`,
|
||||
`AggregateKeyType: "IP"`, `EvaluationWindowSec: 300` (5-min window;
|
||||
accepted values 60/120/300/600). WAF checks ~every 10s.
|
||||
- AWS Managed Rules Common Rule Set = managed rule group
|
||||
`AWSManagedRulesCommonRuleSet` (vendor `AWS`), attached as a separate
|
||||
priority from the rate rule.
|
||||
- CloudFront WebACLs **must** be created in `us-east-1` with
|
||||
`Scope = "CLOUDFRONT"` (regional WebACLs cannot associate with
|
||||
CloudFront).
|
||||
- CloudWatch metrics: per-rule `VisibilityConfig.CloudWatchMetricsEnabled
|
||||
= true`; S3 access logs via `aws_cloudfront_distribution.logging_config`.
|
||||
|
||||
**Recommendation:** WebACL in `us-east-1` `Scope=CLOUDFRONT`; rate rule
|
||||
(3000/5min/IP) + Common Rule Set; associate to JWKS distribution;
|
||||
CloudWatch metrics + S3 access logs.
|
||||
|
||||
## §6 — ACM cert DNS validation + Route53 alias (REQ-366)
|
||||
|
||||
- ACM DNS validation: `aws_acm_certificate` with
|
||||
`validation_method = "DNS"`; create `aws_route53_record` for each
|
||||
`domain_validation_options` CNAME; `aws_acm_certificate_validation`
|
||||
waits on `ISSUED`. For CloudFront, the cert **must** be in
|
||||
`us-east-1`.
|
||||
- Route53 alias: `type = "A"`, `alias { name =
|
||||
aws_cloudfront_distribution.jwks.domain_name; zone_id =
|
||||
aws_cloudfront_distribution.jwks.hosted_zone_id;
|
||||
evaluate_target_health = false }`.
|
||||
- `route53_record_not_resolvable` failure mode: the alias doesn't
|
||||
resolve until CloudFront `status = Deployed` AND ACM cert `ISSUED`. If
|
||||
the validation CNAME is mis-created or Route53 is not authoritative,
|
||||
the CNAME never validates → cert stays `PENDING_VALIDATION` → alias
|
||||
NXDOMAIN.
|
||||
|
||||
**Recommendation:** ACM cert in `us-east-1` DNS validation; validation
|
||||
CNAMEs in the authoritative Route53 zone; `aws_acm_certificate_validation`
|
||||
gates on `ISSUED`; Route53 A-alias to the distribution. Operator guide
|
||||
documents the `route53_record_not_resolvable` → check-cert-status
|
||||
debugging path.
|
||||
|
||||
## §7 — `kj` Go binary static build for AL2023 Lambda (KJ-STATIC, REQ-354, REQ-363)
|
||||
|
||||
- Build: `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s
|
||||
-w" -o kj ./…`. `CGO_ENABLED=0` is load-bearing — no cgo, no dynamic
|
||||
libc link.
|
||||
- `file(1)` must report `ELF 64-bit LSB executable, x86-64, statically
|
||||
linked` + absence of `shared library`/`interpreter`. Secondary:
|
||||
`readelf -d kj` shows no `NEEDED` entries.
|
||||
- Base image `public.ecr.aws/lambda/python:3.12-al2023`; copy binary to
|
||||
`/opt/kj/kj` `chmod 0555` owned by `sbx_user:1051` (Lambda sandbox
|
||||
user, uid/gid 1051 in AL2023). `0555` + immutable-owned prevents
|
||||
runtime tampering.
|
||||
- Lambda handler invokes `subprocess.run(['/opt/kj/kj', 'apply', …],
|
||||
capture_output=True, check=True)` — `kj` is a substrate binary, not a
|
||||
library; the Python handler is a thin shim. kyverno-json (INV-4) is
|
||||
separate + unaffected.
|
||||
|
||||
**Pitfall:** `CGO_ENABLED=1` (default on systems with gcc) produces a
|
||||
dynamically-linked binary; AL2023 glibc mismatch → runtime
|
||||
`GLIBC_X not found`. `CGO_ENABLED=0` eliminates this.
|
||||
|
||||
**Recommendation:** `publish.yml` P1 builds with `CGO_ENABLED=0
|
||||
GOOS=linux GOARCH=amd64`, asserts `file` reports `statically linked` +
|
||||
no `shared library` (fail build otherwise), copies to `/opt/kj/kj`
|
||||
`chmod 0555`, handler calls `subprocess.run(['/opt/kj/kj', 'apply', …])`.
|
||||
|
||||
## §8 — ECR image tag format (REQ-354 AC 3) — SPEC CORRECTION (D-239)
|
||||
|
||||
- **ECR image tags do NOT allow `+`.** The ECR tag regex is
|
||||
`^[a-zA-Z0-9]+(?:[._-][a-zA-Z0-9]+)*$` — permitted chars
|
||||
`[a-zA-Z0-9._-]` only; `+` is rejected by `PutImage`/`BatchGetImage`
|
||||
with `InvalidParameterException`.
|
||||
- The spec's tag format `v1.29.x+kj-<sha>` is **invalid** as written.
|
||||
Correct format: **`v1.29.x-kj-<sha>`** (replace `+` with `-`).
|
||||
- The digest is the immutable trust surface regardless of the tag string
|
||||
— a re-tag is detectable only via digest mismatch. The tag is a human
|
||||
hint, not a security boundary.
|
||||
|
||||
**Decision D-239 (spec correction):** REQ-354 AC 3 tag format corrected
|
||||
to `v1.29.x-kj-<sha>`. Confidence 0.95. Applied to REQUIREMENTS.md
|
||||
§v1.29 REQ-354 AC (3).
|
||||
|
||||
## §9 — Codebase inspection (actual file paths)
|
||||
|
||||
| Target | Path | Summary |
|
||||
|---|---|---|
|
||||
| `publish.yml` | `.github/workflows/publish.yml` (165 lines) + `.gitea/workflows/publish.yml` mirror | Currently publishes wheel + Lambda layer on `push: branches: [main]` (NOT tag-triggered). P1 must change trigger to `on: push: tags: ['v1.29.*']` + attach Lambda zip + ECR image to GitHub Releases. |
|
||||
| `nova/idp/setup.py` CFN | `nova/idp/setup.py` (40 lines, thin CLI dispatcher) + `core/lambda/nova_idp_setup.py` (actual CFN logic, importlib-loaded because `lambda` is reserved) | REQ-369 archives to `docs/archive/nova-idp-cfn-v1.28.md`; `--apply` delegates to `terraform apply`. |
|
||||
| `platform/abac/kj-version.txt` | `platform/abac/kj-version.txt` (2 lines: `v0.0.3` + SHA `4ebb9a19...`) | Already pins `kj` v0.0.3 + source SHA from v1.28 P4. P1 reads this SHA to embed in the ECR tag + verify the build. |
|
||||
| `.gitea/` scrub targets | `.gitea/workflows/` (7 files) + `scripts/sync_workflows.py` (line 26: `GITEA_DIR`), `scripts/sync_to_nova.sh`, `scripts/rotate_spike_key.sh`, `terraform/bootstrap/`, ~100 `.ciagent/` doc matches | REQ-367 P2 removes `.gitea/`, scrubs `gitea` from `.github/` `docs/` `pyproject.toml` `README.md` `.ciagent/`, asserts `forge_parity_disabled` in CI (D-232). `sync_workflows.py` is the central removal target. |
|
||||
| Consumer `deploy.yml` | NOT in `acdl/.github/workflows/deploy.yml` (that's the platform reusable workflow). Consumer's deploy.yml is in the `nova-blockchain-exchange` project — documented at `.ciagent/nova-blockchain-exchange/REQUIREMENTS.md` (REQ-314) + `.ciagent/nova-blockchain-exchange/README.md`. | P5 bumps consumer's `uses:` ref `@v1.25` → `@v1.29` in both `.github/workflows/deploy.yml` + `.gitea/workflows/deploy.yml` (consumer's `.gitea/` is out of scope for REQ-367 — that scrub is `acdl/acdl` only) + smoke test. |
|
||||
|
||||
## §10 — Gitea Actions HITL approval (REQ-357, TFM-HITL)
|
||||
|
||||
- Gitea Actions has **no Environments API** with required reviewers. The
|
||||
approval signal is `gitea.actor` (triggering user) +
|
||||
`gitea.triggering_actor` (may differ on re-run — the re-dispatcher).
|
||||
- PR author: `${{ gitea.event.pull_request.user.login }}`. INV-3 check:
|
||||
`${{ gitea.triggering_actor }} != ${{ gitea.event.pull_request.user.login }}`
|
||||
(use `triggering_actor` for re-run safety).
|
||||
- Gitea scoped-workflows (v1.27+) supports **required workflows** that
|
||||
gate PR merges via status checks — but this gates *merge*, not *apply*.
|
||||
- The `workflow_dispatch` approve-input pattern (D-042) is the mechanism:
|
||||
plan runs automatically on PR; apply is a separate `workflow_dispatch`
|
||||
with `approve_apply` input; the apply job asserts INV-3 + fails closed.
|
||||
- **Codebase precedent:** `core/hitl_gates.py` + `core/separation_of_duties.py`
|
||||
(D-042) — `hitl_gates.attest(env, approver)` reads
|
||||
`GITHUB_ACTOR`/`FORGE_ACTOR`, writes to DynamoDB outbox;
|
||||
`separation_of_duties.check` compares approvers. This is the production
|
||||
pattern to extend for `nova-platform-ops` `terraform apply`.
|
||||
|
||||
**Pitfalls:** scoped-workflow required-check enforcement needs branch
|
||||
protection on `main`; a re-run changes `gitea.actor` to the re-dispatcher
|
||||
— use `gitea.triggering_actor` for the effective approver.
|
||||
|
||||
**Recommendation:** `nova-platform-ops` uses `workflow_dispatch`
|
||||
approve-input pattern (extending `hitl_gates.py`/`separation_of_duties.py`);
|
||||
plan auto-runs on PR, apply is `workflow_dispatch` with `approve_apply`;
|
||||
apply job asserts `${{ gitea.triggering_actor }} != ${{ gitea.event.pull_request.user.login }}`;
|
||||
branch protection on `main` + required scoped-workflow status check.
|
||||
|
||||
---
|
||||
|
||||
## New decisions for the decision ledger (research-derived)
|
||||
|
||||
| D-ID | Title | Confidence | Source |
|
||||
|---|---|---|---|
|
||||
| **D-239** | ECR tag format `v1.29.x+kj-<sha>` invalid (`+` not in ECR tag regex) → corrected to `v1.29.x-kj-<sha>` | 0.95 | §8 ECR API PutImage character class |
|
||||
| **D-240** | `lifecycle.precondition` introduced in Terraform v1.2.0 (not v1.4+); ops repo `required_version = ">= 1.2.0"` suffices | 0.98 | §3 Terraform v1.2.0 CHANGELOG |
|
||||
|
||||
Both are spec-vs-reality corrections logged at full autonomy (confidence
|
||||
≥ 0.60 threshold). D-239 is applied to REQUIREMENTS.md §v1.29 REQ-354
|
||||
AC (3). D-240 is documented in the operator guide (P4) for the
|
||||
`nova-platform-ops` `required_version` floor.
|
||||
|
||||
---
|
||||
|
||||
## RESEARCH complete
|
||||
|
||||
All 10 research questions answered with cited findings + concrete
|
||||
recommendations + risks. Two spec corrections (D-239 ECR tag, D-240
|
||||
Terraform precondition floor). The highest-risk item is the M1.5
|
||||
verification gate (Q7 carry-forward — `kj` static build + 3 consecutive
|
||||
rebuilds in `nova-platform-ops` CI). Next: PLAN.
|
||||
@@ -94,6 +94,58 @@
|
||||
checkpoint. 3 phases (P0 pre-execution + P1 author-archive + P2
|
||||
fix-stale-wire + P3 final-review-ship). No REQ-NNN (NFR).
|
||||
|
||||
- **v1.28:** complete (tag `v1.27.6`) — CLI Canonicalization + Identity
|
||||
Layer. Feature milestone. The Nova CLI is installable from internal
|
||||
PyPI (CodeArtifact); every `core/` module is reachable as a `nova
|
||||
<subcommand>` (15 subcommands, argparse-only, ≤50-line thin delegates,
|
||||
CAP-033/034); `nova init` scaffolds `.nova/`; `nova cli-action`
|
||||
composite action published to GitHub + Gitea (byte-identical, NFR-11);
|
||||
`core/mode_resolver.py` (flag → env → credential type →
|
||||
`sys.stdin.isatty()`, D-226); INV-12 audit emission on every
|
||||
invocation. Nova owns its identity layer end-to-end (Nova-idp):
|
||||
`nova-idp-auth` Lambda (sign-up/sign-in/session, Argon2id t=3 m=65536
|
||||
p=1, fail-closed D-228), `nova-idp-token-vend` Lambda (KMS-signed
|
||||
OIDC tokens, ECDSA P-256 / ES256, DER→raw conversion, kyverno-json
|
||||
ABAC fail-closed C-6.1), `nova-idp-jwks` Lambda (JWKS endpoint),
|
||||
`nova-pats` DynamoDB (PAT lifecycle, strong-read revocation D-229,
|
||||
60s SLO), `nova auth login/revoke/status`, `nova idp setup
|
||||
--check/--apply/--verify` (CloudFormation, NFR-10 explicit approval).
|
||||
No AWS-managed identity (INV-15). 31 requirements (REQ-323..353), 6
|
||||
capabilities (CAP-033..038), 6 invariants (INV-12..17), 6 decisions
|
||||
(D-226..231). 6 execution phases (P1 cli-substrate, P2
|
||||
lambda-packaging, P3 idp-auth, P4 token-vend-pat + idp-setup folded
|
||||
C-2.1, P5 docs-integration, P6 final-review-ship). Grill:
|
||||
PROCEED-WITH-CONDITIONS (0.76), 3 critical fixes (ABAC fail-closed,
|
||||
JWS KDF, traceability drift) + 16 tracked conditions applied. 1000
|
||||
tests passing. Tags: `v1.27.0` (P0) → `v1.27.1..v1.27.5` (P1..P5) →
|
||||
`v1.27.6` (P6 final = milestone release).
|
||||
|
||||
- **v1.29 (active, milestone branch `milestone/v1.29-reposplit-
|
||||
identity`):** Reposplit + Identity Layer Bring-Live. Feature milestone.
|
||||
v1.29 extracts all live platform components (Nova-idp Lambdas, KMS keys,
|
||||
DynamoDB tables, S3 state buckets, OIDC roles, JWKS, audit outbox
|
||||
bootstrap) from `acdl/acdl` into a dedicated Gitea-private Terraform
|
||||
repository (`nova-platform-ops`), brings Nova-idp live in account
|
||||
`581513795199` for the first time (code complete since v1.28, unverified
|
||||
in-account at Phase 0), and standardizes `acdl/acdl` on GitHub. The
|
||||
split enforces Vision §4 domain boundaries architecturally —
|
||||
engineering ends at the compiled artifact; operations begins at the
|
||||
live platform under guardrails. `kj` (a compiled Go binary, pinned
|
||||
v0.0.3 in `platform/abac/kj-version.txt`, distinct from the kyverno-json
|
||||
engine) has exactly one identity: one ECR image digest shared by both
|
||||
the production Lambda runtime and its defensive Fargate fallback
|
||||
(KJ-LOCKSTEP — drift eliminated by construction, enforced by
|
||||
`lifecycle.precondition` at plan time, REQ-371). M1.5 verification gate
|
||||
(8-item spike, 3 consecutive rebuilds) gates M1 cutover. CIAgent in
|
||||
`acdl` delivers the acdl-side work (publish.yml + ECR image, Gitea
|
||||
scrub, CFN archive + CLI terraform-delegation, operator guide,
|
||||
consumer deploy bump); the Terraform modules for `nova-platform-ops`
|
||||
are authored out-of-band (covered-reference REQs with cutover gates as
|
||||
the verification surface). 17 requirements (REQ-354..369 + 371 +
|
||||
363b), 7 decisions (D-232..238), 1 invariant (INV-18 JWKS-EDGE-ONLY) +
|
||||
10 NFR constraints. Tags: `v1.28.0` (P0) → `v1.28.1..v1.28.5` (P1..P5)
|
||||
→ `v1.28.6` (P6 final = milestone release).
|
||||
|
||||
> **Full v1.0–v1.24 phase detail, wave ordering, success criteria, and
|
||||
> decision cross-references:** `.ciagent/archive/ROADMAP-v1.0-v1.24.md`.
|
||||
|
||||
|
||||
+217
-5
@@ -11,10 +11,17 @@
|
||||
> *why*, read `NORTH_STAR.md`. For *how*, read `ARCHITECTURE.md`. For
|
||||
> *what was decided*, read `PROJECT.md` load-bearing decisions.
|
||||
>
|
||||
> **Last milestone ship:** v1.27 (`v1.26.3`, 2026-08-19) — PO State Catalog
|
||||
> & Ciagent Compression NFR milestone. No new capabilities this
|
||||
> milestone (NFR); v1.27 authored this file + compressed `.ciagent/`.
|
||||
> **Next update:** at v1.28 ship.
|
||||
> **Last milestone ship:** v1.28 (`v1.27.6`, 2026-08-19) — CLI
|
||||
> Canonicalization + Identity Layer. Feature milestone: Nova CLI
|
||||
> installable from CodeArtifact; 15 `nova <subcommand>` subcommands;
|
||||
> `nova init` scaffolding; `nova cli-action` composite action;
|
||||
> `core/mode_resolver.py` (D-226); Nova-idp identity layer
|
||||
> (`nova-idp-auth` + `nova-idp-token-vend` + `nova-idp-jwks` Lambdas;
|
||||
> Argon2id; KMS-signed OIDC ES256; kyverno-json ABAC fail-closed; PAT
|
||||
> lifecycle; `nova idp setup`; `nova auth login/revoke/status`). No
|
||||
> AWS-managed identity (INV-15). 6 new capabilities (CAP-033..038),
|
||||
> 6 new invariants (INV-12..17), 6 decisions (D-226..231).
|
||||
> **Next update:** at v1.29 ship.
|
||||
|
||||
## How to use this file (PO)
|
||||
|
||||
@@ -80,6 +87,29 @@
|
||||
path deferred (D-126). Multi-cloud deferred. Multi-validator BFT
|
||||
deferred. The pilot runs `mode: full` for `dev` only (D-209); qa/prod/dr
|
||||
stay placeholder (D-208, blocked by the pilot-readiness policy).
|
||||
- **INV-12 (Mode observability, v1.28):** Every CLI invocation emits a
|
||||
`cli.invocation` audit event containing `mode`, `selection_reason`,
|
||||
`credential_type`, `command`, and `args`. Operators can debug mode
|
||||
selection without reproducing.
|
||||
- **INV-13 (Mode resolution determinism, v1.28):** Resolution priority
|
||||
is flag → env (`NOVA_CLIENT_MODE`) → credential type →
|
||||
`sys.stdin.isatty()`. No silent fallbacks. Invalid env values are
|
||||
ignored + warned. Deviations rejected at PR time.
|
||||
- **INV-14 (Credential type encodes role, v1.28):** `developer_pat` /
|
||||
`nova_oidc_token` + TTY present → `interactive`; TTY absent → `agent`.
|
||||
- **INV-15 (No AWS-managed identity in path, v1.28):** Nova-idp MUST
|
||||
NOT depend on Cognito, IAM Identity Center, or any AWS-managed
|
||||
identity service. Greenfield constraint (no Cognito existed to
|
||||
"drop").
|
||||
- **INV-16 (Password storage, v1.28):** Passwords hashed with Argon2id
|
||||
(t=3, m=65536 KiB, p=1). Fail-closed on `ImportError` (D-228 amended
|
||||
— no pure-Python fallback). Raw passwords never in logs/traces/env/
|
||||
DynamoDB.
|
||||
- **INV-17 (ABAC discipline, v1.28):** The token-vend Lambda evaluates
|
||||
the kyverno-json ABAC policy before signing. Fail-closed on `kj`
|
||||
absence or evaluation error (C-6.1 — never fail open). Allow/deny +
|
||||
policy inputs emitted to the audit stream. `policy_version` (git SHA,
|
||||
D-231) recorded in every event.
|
||||
|
||||
## Domains (capability groups)
|
||||
|
||||
@@ -248,6 +278,31 @@
|
||||
| — | IAM policy baseline (`acdl-spike-runner-policy`) | v1.11 / `v1.11.0` | `terraform/bootstrap/spike_runner_policy.json`, `.ciagent/IAM_POLICY.md` | REQ-116, D-095 | live-aws | regression-tested by `tests/test_iam_policy_baseline.py`; OIDC role `acdl-act-runner-role` (CAP-022) |
|
||||
| — | Local emulating adapters (no AWS) | v1.10 / `v1.10.0` | `core/local_lambda_stub.py`, `scripts/run_local_e2e.sh` | D-092 | local | proves runtime behavior without live AWS |
|
||||
|
||||
### Domain 11 — CLI + Identity Layer (v1.28)
|
||||
|
||||
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||
|----|-----------|---------|-------|-------------|------|-------|
|
||||
| CAP-033 | CLI subcommand surface exists | v1.28 / `v1.27.1` | `nova/cli.py`, `nova/<module>.py` (15 subcommands) | REQ-324 | local | `nova --help` lists a subcommand for every `core/` module; argparse-only, auto-discovered |
|
||||
| CAP-034 | Subcommand delegates to `core/` | v1.28 / `v1.27.1` | `nova/<module>.py` | REQ-324 | local | ≤50 lines, ≤3 FunctionDef, all calls resolve to `core.*` imports; AST-scanned in `tests/test_cli_subcommands.py` |
|
||||
| CAP-035 | Layer matches wheel | v1.28 / `v1.27.1` | `.github/workflows/publish.yml`, `.gitea/workflows/publish.yml`, SSM `/nova/layer/nova-cli/version` | REQ-323 | local | wheel + Lambda layer co-published with identical version; SSM mapping; CodeArtifact + fallback |
|
||||
| CAP-036 | Nova-idp auth flow works | v1.28 / `v1.27.3` | `core/lambda/nova_idp_auth.py`, `tests/test_idp_auth.py` | REQ-333 | local | sign-up → sign-in → session E2E; Argon2id t=3 m=65536 p=1; fail-closed D-228; moto locally, real DDB in CI |
|
||||
| CAP-037 | Token-vend signs via KMS | v1.28 / `v1.27.4` | `core/lambda/nova_idp_token_vend.py`, `core/kms_signing.py`, `tests/test_kms_roundtrip.py` | REQ-337 | local | ECDSA P-256 / ES256; DER→raw conversion; KMS round-trip test; mock KMS locally, real KMS in CI |
|
||||
| CAP-038 | PAT issuance + revocation | v1.28 / `v1.27.4` | `core/pat_lifecycle.py`, `tests/test_pat_revocation.py` | REQ-342 | local | issue → vend → revoke → 403 within 60s P95; strong-read DDB (D-229); verified <1s locally |
|
||||
| — | `nova init` scaffolds `.nova/` | v1.28 / `v1.27.1` | `nova/init.py`, `core/init_scaffold.py` | REQ-325 | local | `.nova/`, `.nova/contract.yml.attestations/`, `.gitignore` (secrets excluded) |
|
||||
| — | `nova cli-action` composite action | v1.28 / `v1.27.1` | `.github/actions/nova-cli/action.yml` | REQ-326 | local | byte-identical GitHub + Gitea; Python 3.12 pinned; NFR-11 |
|
||||
| — | `mode_resolver` (flag→env→cred→TTY) | v1.28 / `v1.27.1` | `core/mode_resolver.py` | REQ-327, D-226 | local | `sys.stdin.isatty()` (not stdout); hypothesis property tests |
|
||||
| — | Dual-use Lambda/CLI import | v1.28 / `v1.27.2` | `core/lambda/contract_ingestor.py` | REQ-329 | local | shared `dispatch_action()`; ≥80% code share; NFR-7 |
|
||||
| — | Local env synthesizer | v1.28 / `v1.27.2` | `core/env.py` (`synthesize_local_env`) | REQ-330 | local | `nova apply --local`; no cloud provisioning |
|
||||
| — | JWS-from-PAT (HKDF-SHA256, HS256) | v1.28 / `v1.27.2` | `core/jws_attestation.py` | REQ-332, C-5.2 | local | symmetric; verification key derived from PAT via same KDF |
|
||||
| — | JWKS endpoint (function URL) | v1.28 / `v1.27.4` | `core/lambda/nova_idp_jwks.py` | REQ-338, D-230 | local | `AuthType: NONE`; `Cache-Control: max-age=3600`; optional CloudFront/WAF |
|
||||
| — | kyverno-json ABAC token-vend policy | v1.28 / `v1.27.4` | `platform/abac/token-vend.policy`, `core/abac_evaluator.py` | REQ-339, D-227 | local | fail-closed (C-6.1, 7 tests); `policy_version` git SHA (D-231) |
|
||||
| — | `nova idp setup --check/--apply/--verify` | v1.28 / `v1.27.4` | `nova/idp/setup.py`, `core/lambda/nova_idp_setup.py`, `core/lambda/nova_idp_cfn.py` | REQ-340, REQ-341 | local | CloudFormation template review (NFR-10); IAM policy delta; KMS round-trip verify |
|
||||
| — | `nova auth login/revoke/status` | v1.28 / `v1.27.4` | `nova/auth/{login,revoke,status}.py`, `core/auth_store.py` | REQ-344, C-7.3 | local | `~/.nova/credentials.json` 0600 stores OIDC token + metadata (NOT raw PAT) |
|
||||
| — | E2E integration test | v1.28 / `v1.27.5` | `tests/test_e2e_idp.py` | REQ-348 | local | sign-up → sign-in → token-vend → apply → audit chain |
|
||||
| — | Identity-layer threat model | v1.28 / `v1.27.5` | `docs/threat-model.md` | REQ-347 | local | 8 threats + C-9.2 INV-18..21 compression audit |
|
||||
| — | Operator guide | v1.28 / `v1.27.5` | `docs/operator-guide-idp.md` | REQ-345 | local | `nova idp setup` + KMS rotation + layer update + PITR restore + emergency PAT revocation |
|
||||
| — | Developer guide | v1.28 / `v1.27.5` | `docs/developer-guide-auth.md` | REQ-346 | local | quickstart + mode resolution + JWS KDF + service-account PATs |
|
||||
|
||||
## Archive pointers
|
||||
|
||||
- **v1.0–v1.24 capability narrative + the 2026-07-27 re-verification sweep:**
|
||||
@@ -283,4 +338,161 @@ wave** (Wave 3 "milestone ship" in `PLAN.md`), alongside
|
||||
Enforcement: convention (the P-final ship step names this file). A
|
||||
drift-check gate (assert every REQ marked `complete` in
|
||||
`REQUIREMENTS.md` traceability appears in STATE.md) is a future option
|
||||
if the convention drifts.
|
||||
if the convention drifts.
|
||||
|
||||
---
|
||||
|
||||
## PDLC Phase 0 Intake (current ground truth — 2026-08-19)
|
||||
|
||||
> Single-pass discovery for the next PDLC cycle. Populated from the
|
||||
> live repo state after v1.28 ship. No aspirational items — state is
|
||||
> what is, not what should be. Unknowns are explicit.
|
||||
|
||||
---
|
||||
|
||||
### 1. Header (mandatory)
|
||||
|
||||
Project: Nova — The New Dawn of DevSecOps
|
||||
|
||||
Initiative: UNKNOWN — needs investigation (no new initiative specified; v1.28 just shipped, next milestone not yet scoped)
|
||||
|
||||
Initiator: Product Owner / Manager (PDLC Phase 0 trigger)
|
||||
|
||||
Date (UTC): 2026-08-19
|
||||
|
||||
Current Version: v1.28 complete (tag `v1.27.6`, merged to main 2026-08-19); all 7 phases shipped; no phase in progress
|
||||
|
||||
System Health: YELLOW — coverage 73.8% is below the 80% release-gate floor (NFR/quality debt); CodeArtifact not provisioned (P1 Wave 0 gate unresolved — fallback documented); KMS asymmetric key unverified in-account (C-1.1 documented as CI gate, not verified locally)
|
||||
|
||||
Raw Idea (≤ 3 sentences):
|
||||
|
||||
UNKNOWN — needs investigation (no raw idea provided; the PDLC trigger is the post-v1.28 state intake, not a new initiative).
|
||||
|
||||
Trigger: v1.28 milestone completion (CLI Canonicalization + Identity Layer shipped 2026-08-19).
|
||||
|
||||
Desired outcome: UNKNOWN — the PO defines the next initiative from this intake.
|
||||
|
||||
---
|
||||
|
||||
### 2. Architecture State
|
||||
|
||||
Active Layers (which exist and are stable):
|
||||
|
||||
[x] Core Primitives — `core/` (27 modules): `abac_evaluator`, `attestation_matrix`, `auth_store`, `confidence_signal`, `contract_resolver`, `decommission_transform`, `env`, `env_transition`, `environment_check`, `hitl_gates`, `init_scaffold`, `jws_attestation`, `kms_signing`, `local_emulators`, `mode_resolver`, `onboarding`, `outbox_writer`, `output_publisher`, `pat_lifecycle`, `policy_engine`, `regression_verify`, `separation_of_duties`, `submission_readiness` + `core/lambda/` (6 modules) + `core/metrics/` (decision ledger)
|
||||
|
||||
[x] Domain Modules — `adapters/terraform/` (stateless adapter), `adapters/kyverno-json/` (unified policy engine, INV-4 swappable), `adapters/wiz/`, `adapters/kyverno/` (K8s, inactive for Terraform — D-053)
|
||||
|
||||
[x] API/Dev Surface — `nova/` CLI package (15 subcommands, argparse-only, `[project.scripts] nova = "nova.cli:main"`); `nova auth {login,revoke,status}`; `nova idp setup`; `nova init`; `nova apply --local`; `nova cli-action` composite action (GitHub + Gitea)
|
||||
|
||||
[x] UI/Agent Surface — N/A (no UI; CLI + JSON endpoints only; JWKS serves `application/json`)
|
||||
|
||||
Compute Topology (per environment):
|
||||
|
||||
local: abstract (local emulators via `core/local_emulators.py:LocalLambdaStub`; `nova apply --local` synthesizes env via `core/env.synthesize_local_env()`; no cloud provisioning)
|
||||
|
||||
dev: abstract (env JSON `core/environments/dev.json`; pilot ran `mode: full` against live AWS `581513795199` at v1.26; Nova-idp Lambdas deploy via `nova idp setup` but not yet live-verified in dev)
|
||||
|
||||
staging: N/A (no `staging` environment JSON; environments are dev/qa/prod/dr)
|
||||
|
||||
prod: UNKNOWN — needs investigation (env JSON `core/environments/prod.json` exists; live-apply not run against prod; pilot was dev-only per D-209)
|
||||
|
||||
dr: placeholder (env JSON `core/environments/dr.json` exists; blocked by pilot-readiness policy D-208; not activated)
|
||||
|
||||
Identity Stack in Force:
|
||||
|
||||
auth: Custom IDP — Nova-idp (`nova-idp-auth` Lambda, v1.28): sign-up/sign-in/session; Argon2id (t=3, m=65536, p=1); DynamoDB `nova-users`/`nova-sessions`/`nova-password-resets`. NOT live-deployed (code + tests complete; `nova idp setup` ready; deployment pending operator action + AWS creds).
|
||||
|
||||
token-vend: Nova-idp (`nova-idp-token-vend` Lambda, v1.28): accepts PAT/session → KMS-signed OIDC token (ECDSA P-256 / ES256); kyverno-json ABAC fail-closed (INV-17, C-6.1); `nova-pats` DynamoDB (strong-read revocation, D-229, 60s SLO). NOT live-deployed.
|
||||
|
||||
signing: KMS asymmetric — `alias/nova-oidc-signing` (ECC_NIST_P256, SIGN_VERIFY). Code complete; key NOT yet created in-account (C-1.1 documented as CI gate — `aws kms create-key --key-spec ECC_NIST_P256 --key-usage SIGN_VERIFY` unverified).
|
||||
|
||||
session: DynamoDB — `nova-sessions` table (PK `session_id`, TTL `expires_at`, 24h). Cookie/local-file: `~/.nova/credentials.json` (0600, OIDC token + PAT metadata, NOT raw PAT — C-7.3).
|
||||
|
||||
Audit Stream:
|
||||
|
||||
source of truth: DynamoDB outbox → S3 Object Lock (7-yr) → GitHub/Gitea audit repo (hot index). The Decision Ledger (SQLite hash-chain, D-121, `core/metrics/decision_ledger.py`) is the cold store for `ai.decision.made` + `attestation.recorded` events.
|
||||
|
||||
in-repo fallback: yes (SQLite hash-chain outbox_writer, `core/outbox_writer.py`, INV-6 tamper-evident; tamper-*resistant* deferred — D-083 S3 Object Lock/JWS not yet enabled)
|
||||
|
||||
retention policy: 7 years (S3 Object Lock target; not yet enabled — D-083 deferred)
|
||||
|
||||
---
|
||||
|
||||
### 3. Technical Stack (concrete, not aspirational)
|
||||
|
||||
Language(s) and runtime(s): Python 3.12 (requires-python `>=3.12`; Lambda Python 3.12 runtime on Amazon Linux 2023)
|
||||
|
||||
Build / packaging: setuptools (`pyproject.toml`, build-backend `setuptools.build_meta`); wheel via `python -m build --wheel`; Lambda layer via `pip install --target layer/python/` + `zip`; publish to CodeArtifact (NOT yet provisioned — fallback: Gitea wheel index / private PyPI via `NOVA_WHEEL_INDEX`)
|
||||
|
||||
CI / CD: Gitea Actions (`.gitea/workflows/`) + GitHub Actions (`.github/workflows/`, byte-identical); `publish.yml` (wheel + layer co-publish, REQ-323, CAP-035); `ci.yml` (test/lint); `deploy.yml@v1.25` (consumer deploy); `nova cli-action` composite action (`.github/actions/nova-cli/action.yml`); OIDC to AWS (`id-token: write`)
|
||||
|
||||
Infrastructure: AWS account `581513795199` (single-region `us-east-1`); S3 (state files); DynamoDB (locking + outbox + identity tables); Lambda (contract ingestor + Nova-idp 3 Lambdas); KMS (per-stack CMK + `alias/nova-oidc-signing`); CloudFront/WAF/ACM (optional, `--public-jwks-domain`); no VMs/bare metal/OS (Anti-Goal)
|
||||
|
||||
Data stores: DynamoDB — `nova-contracts`, `nova-change-requests` (v1.7); `nova-users`, `nova-sessions`, `nova-password-resets`, `nova-pats` (v1.28); SQLite — Decision Ledger (`core/metrics/decision_ledger.py`, local cold store); S3 — Terraform state + audit Object Lock (target, D-083 deferred)
|
||||
|
||||
Secrets / KMS: KMS per-stack CMK (D-069, 90-day rotation); `alias/nova-oidc-signing` (ECC_NIST_P256, 90-day rotation target — code complete, key not yet created); `nova-spike-runner` IAM user (static key, daily rotation via `workflows-src/rotate-aws-key.yml`, REQ-230 forge-agnostic); Secrets Manager (`nova/github-token`); `NOVA_GITEA_TOKEN` in `.env` (not shell-env, per bash_allowlist)
|
||||
|
||||
External integrations in scope: CodeArtifact (internal PyPI — NOT yet provisioned); Gitea (`git.cloudinit.dev/continuous-intelligence/acdl` — primary forge); GitHub (mirror, byte-identical workflows); AWS (account `581513795199` — pilot + identity stack); `kj` / kyverno-json v0.0.3 (Go binary, pinned SHA256, bundled in Lambda layer — `platform/abac/kj-version.txt`)
|
||||
|
||||
---
|
||||
|
||||
### 4. Active Constraints (the load-bearing ones)
|
||||
|
||||
Locked Decisions: D-001..D-231 (full ledger in PROJECT.md + CLARIFY history). Load-bearing for new work: D-022 (contract schema), D-039/D-047 (per-run creds), D-051 (Lambda Function URL), D-069 (per-stack CMK), D-083 (S3 Object Lock — deferred), D-092 (local emulators), D-096 (live pilot — lifted v1.26), D-121 (Decision Ledger), D-133 (submission-readiness gate), D-200..D-213 (v1.26 pilot), D-214..D-225 (v1.27), D-226..D-231 (v1.28 — mode resolution, kyverno-json ABAC, Argon2id fail-closed, PAT revocation strong-read, JWKS function URL, ABAC policy git-SHA versioning)
|
||||
|
||||
Active Invariants: INV-1..INV-17 (full text above). New in v1.28: INV-12 (mode observability), INV-13 (mode determinism), INV-14 (credential type encodes role), INV-15 (no AWS-managed identity), INV-16 (Argon2id password storage), INV-17 (ABAC discipline fail-closed)
|
||||
|
||||
Standing Capability Gate: CAP-001..CAP-038 — all Verified (32 from v1.0..v1.27 + 6 from v1.28). Gate enforced by `core/regression_verify.py` + CI merge gates. CAP-033..038 added v1.28 (CLI surface, delegation AST, layer/wheel match, auth flow, KMS sign, PAT revocation).
|
||||
|
||||
Anti-Goals Touched: `docs/vision.md` §7 / `NORTH_STAR.md` §Anti-Goals — (1) not an upstream dev platform; (2) not a general-purpose AI; (3) not a legacy infra bridge; (4) not a permissive delivery highway; (5) not a mutable audit log. v1.28 honored all 5 (no PDLC reach, narrow CLI autonomy, no VMs, ABAC fail-closed + HITL gates intact, immutable outbox).
|
||||
|
||||
Out-of-Scope (hard): MFA/TOTP enforcement (v1.21+); WebAuthn/FIDO2 (v1.23+); upstream IdP federation (v1.23+); Lambda layer auto-update on `core/` changes (v1.19); password breach detection (v1.23+); session refresh token rotation (v1.22); S3 Object Lock / JWS tamper-resistance (D-083, deferred); multi-cloud (Azure/GCP); ML forecasting; bonds/derivatives/options (D-200 equities-only); multi-validator BFT (D-201 single-validator PoA)
|
||||
|
||||
---
|
||||
|
||||
### 5. Recent History & Quality Gates (last 1-2 milestones)
|
||||
|
||||
Last Shipped: v1.28 (tag `v1.27.6`, 2026-08-19) — CLI Canonicalization + Identity Layer. 31 REQs (REQ-323..353), 6 CAPs (CAP-033..038), 6 INV (INV-12..17), 6 decisions (D-226..231). 7 phases (P0 + P1..P5 + P6 final). 1000 tests passing. Grill PROCEED 0.76 (3 critical + 16 tracked conditions resolved). Merged to main `c0cb188`.
|
||||
|
||||
In Progress: N/A (no phase in progress; v1.28 complete; next milestone not yet scoped)
|
||||
|
||||
Coverage Floor: 73.8% (3119/4227 lines covered) — BELOW the 80% release-gate floor. v1.28 new modules (`nova/`, `core/mode_resolver.py`, `core/lambda/nova_idp_*.py`, `core/kms_signing.py`, `core/abac_evaluator.py`, `core/jws_attestation.py`, `core/pat_lifecycle.py`) have high unit-test coverage but the overall floor is dragged by older uncovered code paths. Quality debt to address in a future NFR milestone.
|
||||
|
||||
Recent Incidents: none (no incidents in v1.27 or v1.28; no hotfix/rollback/outage commits in recent history)
|
||||
|
||||
Known Tensions: (1) CodeArtifact not provisioned — the publish pipeline (REQ-323) has a documented Gitea wheel-index fallback (`NOVA_WHEEL_INDEX`) but the primary path is unverified. (2) KMS asymmetric key unverified in-account (C-1.1) — the token-vend Lambda code + tests are complete but `aws kms create-key --key-spec ECC_NIST_P256` has not been run against `581513795199`. (3) `kj` Go binary in Lambda layer — pinned + locally verified, but AL2023 Lambda-runtime compatibility is a P2 spike that was not live-verified (D-227 risk; Fargate fallback documented). (4) Coverage 73.8% < 80% floor — the release gate was satisfied by phase-level coverage on new modules, but the overall floor is in debt. (5) `pyproject.toml` version is `1.14.0` (stale — not bumped through v1.15..v1.28; the milestone tags are authoritative, not the pyproject version).
|
||||
|
||||
---
|
||||
|
||||
### 6. Agent Context & Assumptions (Agent Initiators Only)
|
||||
|
||||
Missing Context: (1) The next initiative / raw idea — no new PDLC work was specified; this intake is the post-v1.28 state snapshot. (2) Live AWS verification of Nova-idp — CodeArtifact, KMS asymmetric key, and `kj`-in-Lambda-layer were not live-verified (no AWS creds in the build environment); all have documented fallbacks + CI gates. (3) Prod/dr environment activation status — env JSONs exist but live-apply was dev-only (D-209).
|
||||
|
||||
Agent Assumptions: (1) The PDLC trigger is the post-v1.28 state intake (not a new initiative) — the PO will define the next initiative from this snapshot. (2) Coverage 73.8% is reported as YELLOW system health (below 80% floor) but is not a blocker for the intake — it's quality debt for a future NFR milestone. (3) The 3 unverified-in-account items (CodeArtifact, KMS, kj-in-Lambda) are reported as tensions, not blockers — they have fallbacks + CI gates documented. (4) `pyproject.toml` version `1.14.0` is stale but not load-bearing (milestone tags are authoritative); flagged for a future chore.
|
||||
|
||||
---
|
||||
|
||||
### 7. Canonical State References (Version/Hash)
|
||||
|
||||
Vision/Strategy doc: `docs/vision.md` v0.2 (referenced in PROJECT.md; not version-tagged separately)
|
||||
|
||||
Architecture document: `.ciagent/ARCHITECTURE.md` §12.1..§12.10 (v1.28-appended §12.10 Nova-idp); commit `c0cb188` (main HEAD)
|
||||
|
||||
Last approved SPEC: v1.28 (REQ-323..353, REQUIREMENTS.md §v1.28); commit `c0cb188`
|
||||
|
||||
Decision log: D-001..D-231 (PROJECT.md load-bearing + CLARIFY.md history); last synced commit `c0cb188`
|
||||
|
||||
Invariants catalog: INV-1..INV-17 (STATE.md §Invariants); commit `c0cb188`
|
||||
|
||||
Capability catalog: CAP-001..CAP-038 (STATE.md §Domains 1..11); commit `c0cb188`
|
||||
|
||||
---
|
||||
|
||||
### Ground rules compliance
|
||||
|
||||
1. No prose paragraphs inside sections — field structure used throughout. ✓
|
||||
2. No aspirational items — state is what is (CodeArtifact "NOT yet provisioned", KMS "NOT yet created", prod "UNKNOWN"). ✓
|
||||
3. No restated decisions — referenced D-*/INV-*/CAP-* IDs only. ✓
|
||||
4. Unknowns explicit — "UNKNOWN — needs investigation" used for initiative, prod state, raw idea. ✓
|
||||
5. One file, one format — appended to STATE.md as §PDLC Phase 0 Intake. ✓
|
||||
6. Full shipping workflow + merge to forge upstream, NO release — branch + merge + push only (release skipped per instruction). ✓
|
||||
@@ -13,7 +13,7 @@
|
||||
],
|
||||
"active_project": "acdl",
|
||||
"active_projects": ["acdl", "nova-blockchain-exchange"],
|
||||
"active_milestone": "v1.28",
|
||||
"active_milestone": "v1.29",
|
||||
"autonomy": {
|
||||
"level": "full",
|
||||
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# Developer Guide — Nova Auth (`nova auth`)
|
||||
|
||||
> **REQ-346** — developer guide for `nova auth login`. Covers signup,
|
||||
> signin, login, mode resolution, TTY vs piped stdout behavior, and the
|
||||
> JWS-from-PAT KDF (REQ-332, C-5.2).
|
||||
>
|
||||
> Audience: developers using the Nova CLI to authenticate and run
|
||||
> `nova apply`. For operator-side identity stack deployment, see
|
||||
> `docs/operator-guide-idp.md`.
|
||||
|
||||
## 1. Quickstart (5 steps)
|
||||
|
||||
```sh
|
||||
# 1. Sign up (one-time per user).
|
||||
nova auth signup --email alice@example.com --owner team-a
|
||||
|
||||
# 2. Sign in (returns a session — valid 24h).
|
||||
nova auth signin --email alice@example.com
|
||||
|
||||
# 3. Issue a PAT and log in (session → OIDC token, stored locally).
|
||||
nova auth login --pat <PAT>
|
||||
|
||||
# 4. Initialize a project (one-time per repo).
|
||||
nova init
|
||||
|
||||
# 5. Apply locally + sign a local-review attestation.
|
||||
nova apply --local --sign-local-review --contract .nova/contract.yml --pat <PAT>
|
||||
```
|
||||
|
||||
After step 3, `~/.nova/credentials.json` holds your active OIDC token
|
||||
(see §4). After step 5, the attestation is a JWS verifiable with the
|
||||
PAT-derived key (see §7).
|
||||
|
||||
## 2. `nova auth signup`
|
||||
|
||||
Creates a user in the `nova-users` DynamoDB table. The password is
|
||||
hashed with **Argon2id** (OWASP-minimum parameters: `time_cost=3,
|
||||
memory_cost=65536 KiB, parallelism=1`) — the raw password is **never**
|
||||
stored, logged, or put in any env var (INV-16).
|
||||
|
||||
```sh
|
||||
nova auth signup --email alice@example.com --password '...' --owner team-a
|
||||
```
|
||||
|
||||
What happens server-side (the `nova-idp-auth` Lambda):
|
||||
1. Validates the payload (`email`, `password`, `owner`, `roles`).
|
||||
2. Checks for a duplicate email → `409` if already registered.
|
||||
3. `hash_password(password)` → Argon2id hash string.
|
||||
4. `PutItem` into `nova-users` (`user_id`, `email`, `password_hash`,
|
||||
`owner`, `roles`, `created_at`).
|
||||
5. Emits `auth.sign_up` audit event (carries `user_id` + `email`,
|
||||
never the password).
|
||||
|
||||
If the Argon2 C extension is unavailable, the Lambda returns **503**
|
||||
(fail-closed — no weak hash, no pure-Python fallback; D-228).
|
||||
|
||||
## 3. `nova auth signin`
|
||||
|
||||
Verifies the password and returns a session token.
|
||||
|
||||
```sh
|
||||
nova auth signin --email alice@example.com --password '...'
|
||||
```
|
||||
|
||||
The Lambda:
|
||||
1. Looks up the user by email (GSI `email-index` on `nova-users`).
|
||||
2. `verify_password(password, stored_hash)` — Argon2id verify.
|
||||
3. On mismatch or unknown email → `401 invalid_credentials` (the same
|
||||
message for both, so an attacker can't enumerate emails by timing).
|
||||
4. On success: `create_session(user_id)` writes a row to `nova-sessions`
|
||||
(TTL 24h) and returns `session_id`.
|
||||
|
||||
## 4. `nova auth login`
|
||||
|
||||
Exchanges a PAT (or session) for a Nova OIDC token and stores it
|
||||
locally.
|
||||
|
||||
```sh
|
||||
nova auth login --pat <PAT>
|
||||
# or
|
||||
nova auth login --session <session_token>
|
||||
```
|
||||
|
||||
The flow:
|
||||
1. The CLI calls the `nova-idp-token-vend` Lambda with the PAT.
|
||||
2. The Lambda decodes the PAT's `jti`, does a **strongly-consistent**
|
||||
`GetItem` on `nova-pats` (D-229 — revocation is reflected on the
|
||||
next vend, within 60s P95).
|
||||
3. Evaluates the ABAC policy (`platform/abac/token-vend.policy`) —
|
||||
fail-closed (C-6.1). If the policy engine is unavailable or the
|
||||
policy denies, the vend returns `403`.
|
||||
4. Signs the OIDC token via KMS (`alias/nova-oidc-signing`,
|
||||
`ECC_NIST_P256`, `ECDSA_SHA_256`) and returns it.
|
||||
|
||||
### The credentials file (`~/.nova/credentials.json`)
|
||||
|
||||
**C-7.3 (grill):** the file stores the OIDC token + PAT metadata
|
||||
(`jti`, `exp`, `type`) **ONLY — NOT the raw PAT.** The raw PAT is
|
||||
entered once at `nova auth login` and never persisted. This reduces the
|
||||
filesystem-compromise blast radius: an attacker who reads
|
||||
`credentials.json` gets a short-lived OIDC token (default 15 min), not
|
||||
the long-lived PAT.
|
||||
|
||||
The file is `0600` (owner read/write only). Shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"active_credential_jti": "<jti>",
|
||||
"credentials": [
|
||||
{
|
||||
"jti": "<jti>",
|
||||
"type": "nova_oidc_token",
|
||||
"exp": 1787200000,
|
||||
"token": "<oidc jwt>",
|
||||
"stored_at": 1787199000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
"Most recent wins": `active_credential_jti` points at the
|
||||
most-recently-stored credential. A subsequent `nova auth login`
|
||||
replaces the entry with the same `jti` (or adds a new one).
|
||||
|
||||
## 5. `nova auth status`
|
||||
|
||||
Shows the active credential, the resolved mode, and the
|
||||
`selection_reason`.
|
||||
|
||||
```sh
|
||||
nova auth status
|
||||
```
|
||||
|
||||
Output (JSON):
|
||||
```json
|
||||
{
|
||||
"mode": "interactive",
|
||||
"selection_reason": "credential:developer_pat",
|
||||
"type": "nova_oidc_token",
|
||||
"jti": "...",
|
||||
"exp": 1787200000
|
||||
}
|
||||
```
|
||||
|
||||
If no credential is stored: `{"status": "no active credential"}`.
|
||||
|
||||
## 6. `nova auth revoke --pat <jti>`
|
||||
|
||||
Revokes a PAT by `jti`. Marks the `nova-pats` row `status=revoked`
|
||||
(the row is **retained** for audit, not deleted). The next
|
||||
`nova auth login` with that PAT returns `403 pat_revoked` within 60s
|
||||
P95 (D-229 strong read).
|
||||
|
||||
```sh
|
||||
nova auth revoke --pat <jti>
|
||||
```
|
||||
|
||||
For emergency DDB-level revocation (when the CLI is unavailable), see
|
||||
`docs/operator-guide-idp.md` §9.
|
||||
|
||||
## 7. Mode resolution (D-226)
|
||||
|
||||
The CLI resolves a client mode (`interactive` or `agent`) on every
|
||||
invocation. The mode drives audit observability (INV-12) and some
|
||||
behavioral defaults. The priority is **strict** — no silent fallbacks
|
||||
(INV-13):
|
||||
|
||||
1. **`--mode` flag** (always wins): `nova apply --mode=agent`.
|
||||
2. **`NOVA_CLIENT_MODE` env var**: `export NOVA_CLIENT_MODE=agent`.
|
||||
Invalid values (anything other than `agent` / `interactive`) are
|
||||
**warned and ignored** (fall through to the next level — not a
|
||||
silent fallback, because a warning is emitted).
|
||||
3. **Credential type** (from `~/.nova/credentials.json`): if the active
|
||||
credential is `developer_pat` or `nova_oidc_token`, the mode is
|
||||
`interactive` if a TTY is attached, `agent` otherwise (INV-14).
|
||||
4. **TTY heuristic** (`sys.stdin.isatty()`): `interactive` if stdin is
|
||||
a TTY, `agent` otherwise.
|
||||
|
||||
Every resolution returns a non-empty `selection_reason` (`flag`, `env`,
|
||||
`credential:<type>`, or `tty`) so the audit event is self-explanatory.
|
||||
|
||||
### TTY vs piped stdout — the Edge 3 case
|
||||
|
||||
The TTY check is **`sys.stdin.isatty()`**, not `sys.stdout.isatty()`.
|
||||
This matters when stdout is piped but stdin is still a terminal:
|
||||
|
||||
```sh
|
||||
nova apply | tee log.txt
|
||||
```
|
||||
|
||||
Here `stdout` is a pipe (to `tee`), but `stdin` is still the terminal.
|
||||
So `sys.stdin.isatty()` returns `True` → **interactive mode**. This is
|
||||
the common "I want to see the output AND save it" pattern, and it
|
||||
correctly resolves to interactive because the human is driving.
|
||||
|
||||
The inverse — `echo '...' | nova apply` — has `stdin` piped, so
|
||||
`sys.stdin.isatty()` is `False` → **agent mode** (no human at the
|
||||
keyboard; the pipe is the driver).
|
||||
|
||||
### `developer_pat` + TTY → interactive; + no TTY → agent
|
||||
|
||||
A developer PAT (`type: developer_pat`) is a human credential. When a
|
||||
TTY is attached, the CLI runs in `interactive` mode (prompts, human
|
||||
confirmation). When no TTY is attached (piped stdin, CI, a scheduled
|
||||
job), the same PAT runs in `agent` mode (no prompts, non-interactive).
|
||||
This is INV-14: the credential type encodes the role, and the TTY
|
||||
encodes the context.
|
||||
|
||||
A service-account PAT behaves the same way by type, but the max TTL is
|
||||
much shorter (≤ 1h vs ≤ 24h for developer PATs — C-6.2) and CI systems
|
||||
typically set `NOVA_CLIENT_MODE=agent` explicitly so the resolution is
|
||||
deterministic regardless of the TTY state.
|
||||
|
||||
## 8. JWS-from-PAT key derivation (REQ-332, C-5.2)
|
||||
|
||||
`nova apply --local --sign-local-review` produces a JWS attestation — a
|
||||
symmetric (HMAC-SHA256) signature over the attestation payload, keyed
|
||||
by a key derived from the PAT.
|
||||
|
||||
### Why symmetric?
|
||||
|
||||
The grill (C-5.2) found that the original REQ-332 acceptance criterion
|
||||
("public key derivable from the PAT") is unimplementable as an
|
||||
asymmetric scheme — a PAT is a JWT, not a keypair. The fix: the PAT is
|
||||
the **shared secret**. Both the signing key and the verification key
|
||||
are derived from the PAT via the same KDF. The JWS uses `HS256`
|
||||
(HMAC-SHA256), not `ES256`.
|
||||
|
||||
### The KDF
|
||||
|
||||
```
|
||||
key = HKDF-SHA256(
|
||||
input_key_material = PAT.encode('utf-8'),
|
||||
salt = b'nova-local-attestation',
|
||||
info = b'jws-signing-key',
|
||||
length = 32,
|
||||
)
|
||||
```
|
||||
|
||||
(RFC 5869 / NIST SP 800-56C.) The `salt` and `info` are fixed
|
||||
constants — they bind the derived key to the "nova-local-attestation /
|
||||
jws-signing-key" purpose (key separation, INV-16). The same PAT always
|
||||
yields the same key (deterministic); the key is never cached or
|
||||
persisted (INV-15 — recomputed on each sign/verify call).
|
||||
|
||||
### Signing (`nova apply --local --sign-local-review`)
|
||||
|
||||
```sh
|
||||
nova apply --local --sign-local-review --pat <PAT> --contract .nova/contract.yml
|
||||
```
|
||||
|
||||
1. `core.jws_attestation.sign_attestation(payload, pat)`:
|
||||
- `derive_signing_key(pat)` → 32-byte key.
|
||||
- `header = {"alg":"HS256","typ":"JWT"}`.
|
||||
- `signing_input = b64url(header) + "." + b64url(payload)`.
|
||||
- `signature = HMAC-SHA256(key, signing_input)`.
|
||||
- Returns `b64url(header).b64url(payload).b64url(signature)` (the
|
||||
compact JWS serialization).
|
||||
2. The JWS is appended to the apply output.
|
||||
|
||||
### Verifying
|
||||
|
||||
Anyone holding the PAT can derive the same key and verify:
|
||||
|
||||
```python
|
||||
from core.jws_attestation import verify_attestation
|
||||
payload = verify_attestation(jws_string, pat)
|
||||
# raises JWSValidationError on tampering or wrong PAT
|
||||
```
|
||||
|
||||
`verify_attestation` recomputes the HMAC and compares in constant time
|
||||
(`hmac.compare_digest`). Without the PAT, the HMAC cannot be forged —
|
||||
this is the integrity guarantee for local-review attestations.
|
||||
|
||||
### What this is NOT
|
||||
|
||||
- **Not a non-repudiation scheme.** Anyone with the PAT can sign, so
|
||||
the signature proves "someone with the PAT signed this payload" —
|
||||
not a specific individual. Non-repudiation is the job of the audit
|
||||
trail (INV-12), not the JWS.
|
||||
- **Not a replacement for the OIDC token.** The OIDC token (from
|
||||
`nova auth login`) is the credential for remote operations; the JWS
|
||||
is for local-review attestation integrity only.
|
||||
|
||||
## 9. Service-account PATs (CI usage)
|
||||
|
||||
A CI system (GitHub Actions, or an internal forge runner) uses a service-account
|
||||
PAT to run `nova apply` non-interactively.
|
||||
|
||||
```sh
|
||||
# In CI:
|
||||
export NOVA_PAT=<service-account-pat>
|
||||
export NOVA_CLIENT_MODE=agent
|
||||
nova auth login --pat "$NOVA_PAT"
|
||||
nova apply --contract contracts/microservice.yml
|
||||
```
|
||||
|
||||
- `NOVA_CLIENT_MODE=agent` makes mode resolution deterministic (level 2
|
||||
beats level 3/4), regardless of whether the CI runner attaches a TTY.
|
||||
- No TTY → `agent` mode anyway, but the env var is belt-and-suspenders.
|
||||
- **Max TTL: ≤ 1h for service-account PATs** (C-6.2). The
|
||||
`issue_pat(subject_type="service-account", ttl_seconds=3600)` call
|
||||
clamps any higher request to 3600s. Rotate the PAT before it expires
|
||||
(CI should mint a fresh one per run or daily).
|
||||
|
||||
### TTL summary (C-6.2)
|
||||
|
||||
| Subject type | Max TTL | Typical use |
|
||||
|--------------|---------|-------------|
|
||||
| `developer` | ≤ 24h (86400s) | local dev, interactive |
|
||||
| `service-account` | ≤ 1h (3600s) | CI, automated pipelines |
|
||||
|
||||
The TTL is enforced in `core.pat_lifecycle.issue_pat` — a request for
|
||||
more than the max is silently clamped (with an audit event recording
|
||||
the requested vs actual TTL).
|
||||
|
||||
---
|
||||
|
||||
## Appendix — command reference
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `nova auth signup` | create a user (Argon2id hash) |
|
||||
| `nova auth signin` | verify password → session token |
|
||||
| `nova auth login --pat <PAT>` | PAT → OIDC token, store in `~/.nova/credentials.json` (0600) |
|
||||
| `nova auth status` | active credential + mode + selection_reason |
|
||||
| `nova auth revoke --pat <jti>` | mark a PAT revoked (D-229 SLO ≤ 60s P95) |
|
||||
| `nova apply --local --sign-local-review --pat <PAT>` | local apply + JWS attestation (HS256, PAT-derived key) |
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.nova/credentials.json` | OIDC token + PAT metadata (NOT raw PAT); 0600 |
|
||||
| `~/.nova/contract.yml` | project contract (scaffolded by `nova init`) |
|
||||
| `~/.nova/contract.yml.attestations/` | local attestation outputs |
|
||||
@@ -0,0 +1,385 @@
|
||||
# Operator Guide — Nova IdP Setup (`nova idp setup`)
|
||||
|
||||
> **REQ-345** — operator guide for `nova idp setup`. Covers `--check`,
|
||||
> `--apply`, `--verify`, the prerequisite IAM policy, the CloudFormation
|
||||
> review flow, and the **C-6.3 grill additions**: KMS key rotation
|
||||
> (90 days), Lambda layer update, DDB PITR restore, emergency PAT
|
||||
> revocation (DDB-level, not CLI).
|
||||
>
|
||||
> Audience: platform operators / SREs deploying the Nova identity stack
|
||||
> into AWS account `581513795199` (or a fresh account). No developer
|
||||
> auth flows here — see `docs/developer-guide-auth.md` for those.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
`nova idp setup` provisions the Nova identity layer (Nova-idp) as a
|
||||
CloudFormation stack. The stack contains:
|
||||
|
||||
| Resource | Count | Notes |
|
||||
|----------|-------|-------|
|
||||
| Lambda functions | 3 | `nova-idp-auth`, `nova-idp-token-vend`, `nova-idp-jwks` |
|
||||
| DynamoDB tables | 4 | `nova-users`, `nova-sessions`, `nova-password-resets`, `nova-pats` (PITR enabled on each, REQ-335) |
|
||||
| KMS asymmetric key | 1 | `alias/nova-oidc-signing` (`ECC_NIST_P256`, `SIGN_VERIFY`) |
|
||||
| Lambda function URLs | 3 | auth + token-vend (IAM auth), jwks (`AuthType: NONE`) |
|
||||
| IAM roles | 3+ | one per Lambda + the CloudFormation service role |
|
||||
| Optional CloudFront + WAF + ACM | 0/3 | only with `--public-jwks-domain` |
|
||||
|
||||
The command has three modes — `--check`, `--apply`, `--verify` — plus
|
||||
`--dry-run` for a resource-only preview. All modes are safe to re-run.
|
||||
|
||||
## 2. `nova idp setup --check`
|
||||
|
||||
Run **before** `--apply` to verify the deploying principal has the
|
||||
permissions and environment the stack needs.
|
||||
|
||||
```sh
|
||||
nova idp setup --check
|
||||
```
|
||||
|
||||
### What it checks
|
||||
|
||||
1. **AWS credentials** — `aws sts get-caller-identity` succeeds and
|
||||
returns an `Account` id. If this fails, run `aws configure` or export
|
||||
`AWS_PROFILE` / `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`.
|
||||
2. **AWS region** — `AWS_DEFAULT_REGION` or `AWS_REGION` is set. The
|
||||
stack is regional (single-region); pick the region you want all
|
||||
resources to live in.
|
||||
3. **CloudFormation permissions** — the principal can create/describe
|
||||
stacks (see §5 for the full IAM delta).
|
||||
4. **KMS permissions** — `kms:CreateKey` + `kms:CreateAlias` (needed to
|
||||
mint `alias/nova-oidc-signing`).
|
||||
5. **Lambda layer exists** — the `nova-cli` Lambda layer (published by
|
||||
the P1 Wave 4 pipeline) is referenced by the stack; `--check` reports
|
||||
whether the layer ARN in SSM (`/nova/layer/nova-cli/version`) is
|
||||
present. If absent, run the publish workflow or `nova layer update`.
|
||||
|
||||
### Reading the IAM policy delta
|
||||
|
||||
`--check` prints a report like:
|
||||
|
||||
```json
|
||||
{
|
||||
"aws_creds": true,
|
||||
"region": "us-east-1",
|
||||
"missing": [],
|
||||
"iam_delta": [
|
||||
"cloudformation:*",
|
||||
"iam:CreateRole",
|
||||
"iam:PassRole",
|
||||
"lambda:CreateFunction",
|
||||
"lambda:CreateFunctionUrlConfig",
|
||||
"dynamodb:CreateTable",
|
||||
"kms:CreateKey",
|
||||
"kms:CreateAlias"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`iam_delta` is the **delta** between what the deploying principal
|
||||
currently has (the `nova-spike-runner` grants in this account) and what
|
||||
`--apply` needs. Each entry is a grant you must add to the principal's
|
||||
policy before `--apply` will succeed. `--check` never makes changes.
|
||||
|
||||
## 3. `nova idp setup --apply`
|
||||
|
||||
Generates the CloudFormation template, presents it for review, and
|
||||
deploys **only after explicit `y/N` approval** (NFR-10).
|
||||
|
||||
```sh
|
||||
nova idp setup --apply
|
||||
```
|
||||
|
||||
### Review flow
|
||||
|
||||
1. **Resource summary** printed to stdout (resource type → count):
|
||||
```
|
||||
Resource summary:
|
||||
AWS::DynamoDB::Table: 4
|
||||
AWS::IAM::Role: 3
|
||||
AWS::KMS::Key: 1
|
||||
AWS::Lambda::Function: 3
|
||||
AWS::Lambda::Url: 3
|
||||
```
|
||||
2. **Full template** opened in `$PAGER` (if set and stdin is a TTY);
|
||||
otherwise the path to the temp file is printed. Review every
|
||||
resource, especially the KMS key policy and the IAM roles.
|
||||
3. **`Apply? [y/N]` prompt.** Type `y` + Enter to deploy; anything else
|
||||
aborts. No resource is created before this approval.
|
||||
4. On approval: `aws cloudformation deploy --stack-name nova-idp
|
||||
--template-file <tmp> --capabilities CAPABILITY_IAM`.
|
||||
|
||||
### `--dry-run` — resource list only
|
||||
|
||||
```sh
|
||||
nova idp setup --dry-run
|
||||
```
|
||||
|
||||
Generates the template and prints the resource summary **without** the
|
||||
pager, the prompt, or any deploy. Use this to audit the stack shape in
|
||||
CI or before a manual `--apply`.
|
||||
|
||||
### `--public-jwks-domain` — optional custom domain + WAF
|
||||
|
||||
```sh
|
||||
nova idp setup --apply --public-jwks-domain jwks.nova.example.com
|
||||
```
|
||||
|
||||
Adds a CloudFront distribution fronting the JWKS Lambda function URL, an
|
||||
ACM certificate (DNS-validated) for the domain, and a WAF web ACL with
|
||||
a rate-based rule (see §C-6.3 and the threat model). Without this flag
|
||||
the JWKS endpoint is a bare function URL (`AuthType: NONE`) — fine for
|
||||
piloting but exposed to the internet without rate limiting. **For any
|
||||
public deployment, set `--public-jwks-domain`.**
|
||||
|
||||
## 4. `nova idp setup --verify`
|
||||
|
||||
Runs the KMS round-trip test (CAP-037) against the deployed stack.
|
||||
|
||||
```sh
|
||||
nova idp setup --verify
|
||||
```
|
||||
|
||||
It signs a test JWT via `core.kms_signing.sign_jwt()` (using the real
|
||||
KMS key `alias/nova-oidc-signing`), fetches the JWKS endpoint, and
|
||||
verifies the JWT signature with `pyjwt` + the JWKS key. This exercises
|
||||
the full DER → raw ECDSA conversion path (the #1 implementation risk —
|
||||
see `docs/threat-model.md`).
|
||||
|
||||
**Success output:**
|
||||
```json
|
||||
{"passed": true, "detail": "KMS round-trip OK"}
|
||||
```
|
||||
|
||||
**Failure output:**
|
||||
```json
|
||||
{"passed": false, "detail": "verify error: <exception>"}
|
||||
```
|
||||
|
||||
Common failure causes:
|
||||
- The KMS key policy doesn't grant `kms:Sign` to the verify caller.
|
||||
- The JWKS function URL is not deployed or returns a non-200.
|
||||
- The KMS key spec isn't `ECC_NIST_P256` (the DER→raw conversion
|
||||
assumes P-256, 32-byte coordinates).
|
||||
|
||||
## 5. Required IAM policy
|
||||
|
||||
The delta `--check` reports is the set of grants the deploying
|
||||
principal needs **in addition** to the existing `nova-spike-runner`
|
||||
grants. The full required set:
|
||||
|
||||
| Action | Why |
|
||||
|--------|-----|
|
||||
| `cloudformation:*` | create/deploy/describe the `nova-idp` stack |
|
||||
| `codeartifact:*` | (already on `nova-spike-runner`) publish the wheel + layer |
|
||||
| `iam:CreateRole` | create the per-Lambda execution roles |
|
||||
| `iam:PassRole` | pass those roles to Lambda + CloudFormation |
|
||||
| `lambda:CreateFunction` | create the 3 Lambda functions |
|
||||
| `lambda:CreateFunctionUrlConfig` | create the 3 function URLs |
|
||||
| `dynamodb:CreateTable` | create the 4 DDB tables (with PITR) |
|
||||
| `kms:CreateKey` | mint the `ECC_NIST_P256` signing key |
|
||||
| `kms:CreateAlias` | bind `alias/nova-oidc-signing` to the key |
|
||||
| `ssm:PutParameter` | write the layer-version mapping to SSM |
|
||||
|
||||
Attach these to the deploying principal's policy before `--apply`.
|
||||
`--check` will then report an empty `missing` list.
|
||||
|
||||
---
|
||||
|
||||
## C-6.3 Grill additions — operational runbooks
|
||||
|
||||
The grill (C-6.3) requires four operational procedures beyond the
|
||||
setup flow. Each is a runbook an on-call SRE can follow without reading
|
||||
source code.
|
||||
|
||||
### 6. KMS key rotation (90-day cadence)
|
||||
|
||||
**Cadence:** rotate `alias/nova-oidc-signing` every **90 days**. The
|
||||
rotation is a *key re-point*, not a key deletion — the alias is moved
|
||||
to a new key while the old key stays valid during the token-overlap
|
||||
window so already-issued tokens keep verifying.
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. **Create the new key** (same spec):
|
||||
```sh
|
||||
NEW_KEY=$(aws kms create-key \
|
||||
--key-spec ECC_NIST_P256 \
|
||||
--key-usage SIGN_VERIFY \
|
||||
--description "nova-oidc-signing-$(date +%Y%m%d)" \
|
||||
--query KeyId --output text)
|
||||
```
|
||||
2. **Re-point the alias** to the new key:
|
||||
```sh
|
||||
aws kms update-alias --alias-name alias/nova-oidc-signing \
|
||||
--target-key-id "$NEW_KEY"
|
||||
```
|
||||
3. **JWKS serves both `kid`s during the overlap window.** The JWKS
|
||||
Lambda lists **all** keys the alias has pointed at that are still
|
||||
enabled. Already-issued OIDC tokens (signed with the old key) keep
|
||||
verifying until they expire (OIDC TTL default 15 min; PAT TTL ≤ 24h
|
||||
dev / ≤ 1h service-account). **Do not disable the old key until at
|
||||
least the max PAT TTL (24h) has elapsed.**
|
||||
4. **After the overlap window** (≥ 24h), disable + schedule deletion of
|
||||
the old key:
|
||||
```sh
|
||||
aws kms disable-key --key-id "<old-key-id>"
|
||||
aws kms schedule-key-deletion --key-id "<old-key-id>" --pending-window-in-days 7
|
||||
```
|
||||
5. **Verify** the new key is active:
|
||||
```sh
|
||||
nova idp setup --verify
|
||||
```
|
||||
|
||||
**Audit:** emit a manual `kms.key_rotated` event to the audit stream
|
||||
with `old_key_id`, `new_key_id`, `rotated_at`. The rotation is a
|
||||
CloudFormation-less operation (KMS aliases are mutable); it does not
|
||||
require a stack update.
|
||||
|
||||
### 7. Lambda layer update
|
||||
|
||||
The `nova-cli` Lambda layer (the shared dependency bundle:
|
||||
`argon2-cffi`, `cryptography`, `pyjwt`, `kj` binary) is republished
|
||||
**automatically on every merge to `main`** by the P1 Wave 4 publish
|
||||
workflow (the byte-identical GitHub + internal-forge workflow files).
|
||||
On a successful publish, the new layer version ARN is written to SSM
|
||||
`/nova/layer/nova-cli/version`.
|
||||
|
||||
**When to update manually:**
|
||||
- A dependency CVE requires an out-of-band patch before the next merge.
|
||||
- The `kj` binary pinned version changes (C-8.2 supply-chain safety).
|
||||
|
||||
**Manual procedure:**
|
||||
|
||||
```sh
|
||||
nova layer update
|
||||
```
|
||||
|
||||
This rebuilds the layer (`pip install --target layer/python/` + the
|
||||
pinned `kj` binary, SHA256 verified against `layer/kj.sha256`),
|
||||
publishes a new `lambda:PublishLayerVersion`, and updates the SSM
|
||||
parameter. The 3 Nova-idp Lambdas pick up the new layer on their next
|
||||
cold start (or force a redeploy with `aws lambda update-function-configuration
|
||||
--layers <new-arn>` on each).
|
||||
|
||||
**Verify:** `nova idp setup --verify` after the Lambdas reload.
|
||||
|
||||
### 8. DynamoDB PITR restore
|
||||
|
||||
All 4 identity tables have point-in-time recovery (PITR) enabled
|
||||
(REQ-335): `nova-users`, `nova-sessions`, `nova-password-resets`,
|
||||
`nova-pats`. PITR lets you restore a table to any second in the last
|
||||
**35 days** (the AWS retention window).
|
||||
|
||||
**Procedure (restore `nova-pats` to 1 hour ago):**
|
||||
|
||||
```sh
|
||||
# 1. Find the restore target time (ISO 8601, UTC, within the last 35d).
|
||||
RESTORE_TO=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# 2. Restore to a NEW table (PITR never overwrites the source).
|
||||
aws dynamodb restore-table-to-point-in-time \
|
||||
--source-table-name nova-pats \
|
||||
--target-table-name nova-pats-restored \
|
||||
--restore-date-time "$RESTORE_TO" \
|
||||
--billing-mode-restore-as-is
|
||||
|
||||
# 3. After the restore completes (status ACTIVE), repoint the app:
|
||||
# - update the stack env var NOVA_PATS_TABLE=nova-pats-restored, or
|
||||
# - rename: delete nova-pats, then aws dynamodb update-table --table-name
|
||||
# nova-pats-restored --new-table-name nova-pats (downtime window).
|
||||
# 4. Re-enable PITR on the restored table (PITR does not carry over).
|
||||
aws dynamodb update-continuous-backups \
|
||||
--table-name nova-pats-restored \
|
||||
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
|
||||
```
|
||||
|
||||
**Which tables have PITR:** all 4 (`nova-users`, `nova-sessions`,
|
||||
`nova-password-resets`, `nova-pats`). Verify with:
|
||||
```sh
|
||||
for t in nova-users nova-sessions nova-password-resets nova-pats; do
|
||||
aws dynamodb describe-continuous-backups --table-name "$t" \
|
||||
--query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription' --output text
|
||||
done
|
||||
```
|
||||
|
||||
**Recovery window:** 35 days (AWS PITR). Restores older than 35 days
|
||||
are impossible — for longer retention, export to S3 via the on-demand
|
||||
export or a scheduled AWS Backup plan.
|
||||
|
||||
### 9. Emergency PAT revocation (DDB-level, not CLI)
|
||||
|
||||
**When to use:** a PAT is known-compromised and the `nova auth revoke`
|
||||
CLI is unavailable (e.g. the operator machine is offline, or the PAT
|
||||
`jti` is known but the raw PAT is not — revocation is keyed on `jti`,
|
||||
not the token string). This is a **DDB-level** operation; it bypasses
|
||||
the CLI but still satisfies the D-229 strong-read SLO (the token-vend
|
||||
Lambda does a `ConsistentRead=True` `GetItem` on `jti` on every vend —
|
||||
the revocation is reflected on the next vend, within 60s P95).
|
||||
|
||||
**Procedure:**
|
||||
|
||||
```sh
|
||||
aws dynamodb update-item \
|
||||
--table-name nova-pats \
|
||||
--key '{"jti":{"S":"<jti>"}}' \
|
||||
--update-expression "SET #s = :r" \
|
||||
--expression-attribute-names '{"#s":"status"}' \
|
||||
--expression-attribute-values '{":r":{"S":"revoked"}}'
|
||||
```
|
||||
|
||||
Replace `<jti>` with the PAT's `jti` claim (a uuid4; find it in the
|
||||
`pat.issued` audit event or by scanning the `sub-index` GSI for the
|
||||
compromised subject). The item is **retained** (not deleted) so the
|
||||
audit trail is intact — only `status` flips from `active` to `revoked`.
|
||||
|
||||
**Verify the revocation took effect:**
|
||||
|
||||
```sh
|
||||
aws dynamodb get-item \
|
||||
--table-name nova-pats \
|
||||
--key '{"jti":{"S":"<jti>"}}' \
|
||||
--consistent-read \
|
||||
--query 'Item.status.S' --output text
|
||||
# → revoked
|
||||
```
|
||||
|
||||
The next `token-vend` call with that `jti` returns `403
|
||||
pat_revoked` immediately (D-229: the strong read is synchronous).
|
||||
|
||||
**Bulk revocation** (revoke all of a subject's PATs):
|
||||
|
||||
```sh
|
||||
SUB="<sub>"
|
||||
JTIS=$(aws dynamodb query \
|
||||
--table-name nova-pats \
|
||||
--index-name sub-index \
|
||||
--key-condition-expression "sub = :s" \
|
||||
--expression-attribute-values "{\":s\":{\"S\":\"$SUB\"}}" \
|
||||
--query 'Items[?status.S==`active`].jti.S' --output text)
|
||||
for jti in $JTIS; do
|
||||
aws dynamodb update-item --table-name nova-pats \
|
||||
--key "{\"jti\":{\"S\":\"$jti\"}}" \
|
||||
--update-expression "SET #s = :r" \
|
||||
--expression-attribute-names '{"#s":"status"}' \
|
||||
--expression-attribute-values '{":r":{"S":"revoked"}}'
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix — quick reference
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `nova idp setup --check` | prerequisites + IAM delta (no changes) |
|
||||
| `nova idp setup --dry-run` | resource summary only (no deploy) |
|
||||
| `nova idp setup --apply` | review template → `y/N` → deploy |
|
||||
| `nova idp setup --apply --public-jwks-domain <fqdn>` | add CloudFront + WAF + ACM |
|
||||
| `nova idp setup --verify` | KMS round-trip test (CAP-037) |
|
||||
|
||||
| Runbook | Cadence / trigger |
|
||||
|---------|-------------------|
|
||||
| KMS key rotation | every 90 days |
|
||||
| Lambda layer update | on merge (auto) or manually via `nova layer update` |
|
||||
| DDB PITR restore | on data loss / corruption (35-day window) |
|
||||
| Emergency PAT revocation | on compromise (DDB-level, immediate) |
|
||||
@@ -0,0 +1,408 @@
|
||||
# Nova Identity Layer — Threat Model
|
||||
|
||||
> **REQ-347** — identity-layer threat model. Covers the 8 threats
|
||||
> enumerated below + the **C-9.2 INV-18..21 compression audit**. The
|
||||
> C-6.2 grill additions (JWKS DDoS, PAT max TTL, ABAC fail-closed) are
|
||||
> integrated into the threat list, not appended.
|
||||
>
|
||||
> Scope: the Nova-idp identity layer (`nova-idp-auth` +
|
||||
> `nova-idp-token-vend` + `nova-idp-jwks` Lambdas, the KMS signing key,
|
||||
> the 4 DynamoDB tables, the `nova auth` CLI, the PAT lifecycle). Out
|
||||
> of scope: the downstream contract resolver, Terraform adapter, and
|
||||
> consumer-side auth (those have their own threat models).
|
||||
|
||||
## 1. Assets
|
||||
|
||||
| Asset | Where | Sensitivity |
|
||||
|-------|-------|-------------|
|
||||
| User passwords | `nova-users.password_hash` (Argon2id) | high — hash only; raw never stored |
|
||||
| PATs (personal access tokens) | `nova-pats` (hash only) + returned to caller once | high — bearer token, ≤24h/≤1h TTL |
|
||||
| OIDC tokens | `~/.nova/credentials.json` (0600) + in-flight to clients | medium — short-lived (15 min default) |
|
||||
| KMS signing key | KMS `alias/nova-oidc-signing` (`ECC_NIST_P256`) | high — the trust anchor for all OIDC tokens |
|
||||
| ABAC policy | `platform/abac/token-vend.policy` (git-tracked) | high — the authorization rules |
|
||||
| DynamoDB tables | `nova-users`, `nova-sessions`, `nova-password-resets`, `nova-pats` | high — the identity store |
|
||||
| JWKS endpoint | `nova-idp-jwks` function URL (`AuthType: NONE`) | medium — public, must be available but is not secret |
|
||||
| Audit stream | stderr JSON from each Lambda + the CLI | high — tamper-evidence for the whole layer |
|
||||
|
||||
## 2. Trust boundaries
|
||||
|
||||
```
|
||||
┌────────────────┐ IAM-auth function URL ┌────────────────────┐
|
||||
│ Developer CI │ ───────────────────────────► │ nova-idp-auth │
|
||||
│ (nova CLI) │ │ nova-idp-token-vend│
|
||||
│ │ ◄────── OIDC token ───────── │ (KMS sign) │
|
||||
└────────┬───────┘ └─────────┬──────────┘
|
||||
│ │
|
||||
│ ~/.nova/credentials.json (0600) │ strong-read GetItem
|
||||
│ NOT the raw PAT ▼
|
||||
│ ┌────────────────────┐
|
||||
│ │ nova-pats (DDB) │
|
||||
│ │ nova-users/sessions│
|
||||
│ JWKS fetch (unauthenticated) └────────────────────┘
|
||||
│ ──────────────────────────────────► ┌────────────────────┐
|
||||
│ │ nova-idp-jwks │
|
||||
│ ◄──── public key (JWK) ──────────── │ (AuthType: NONE) │
|
||||
▼ └────────────────────┘
|
||||
┌────────────────┐
|
||||
│ AWS KMS │ kms:Sign (token-vend role only)
|
||||
│ alias/nova- │ kms:GetPublicKey (jwks role)
|
||||
│ oidc-signing │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
The key boundary crossings:
|
||||
1. **Internet → JWKS Lambda** (unauthenticated function URL) — the
|
||||
DDoS surface (Threat T-4).
|
||||
2. **CLI → auth/token-vend Lambdas** (IAM-authenticated function URLs)
|
||||
— the credential-injection surface.
|
||||
3. **token-vend Lambda → KMS** (`kms:Sign`) — the key-use surface.
|
||||
4. **token-vend Lambda → DDB** (strong read on `nova-pats`) — the
|
||||
revocation surface.
|
||||
|
||||
## 3. Threats + mitigations
|
||||
|
||||
### T-1 — Password compromise (storage)
|
||||
|
||||
**Threat:** an attacker with read access to `nova-users` (DDB export,
|
||||
backup, a leaked snapshot) recovers plaintext passwords.
|
||||
|
||||
**Mitigations:**
|
||||
- **Argon2id hashing** with OWASP-minimum parameters
|
||||
(`time_cost=3, memory_cost=65536 KiB, parallelism=1`) —
|
||||
`core/lambda/nova_idp_auth.py:hash_password`. Argon2id is the
|
||||
recommended PHC winner; the parameters are the OWASP minimum (C-7.2).
|
||||
- **Fail-closed on Argon2 unavailable** (D-228): if the `argon2-cffi`
|
||||
C extension fails to import, `_ARGON2_AVAILABLE` is `False` and
|
||||
`hash_password`/`verify_password` raise `Argon2UnavailableError` →
|
||||
the handler returns **503**. **No pure-Python fallback, no weak
|
||||
hash, no crash.** Verified by `tests/test_argon2_fail_closed.py`.
|
||||
- **No raw passwords anywhere** (INV-16): the handler never logs the
|
||||
password argument; the audit scrubber (`_emit_audit`) pops any
|
||||
`password`/`new_password`/`old_password` kwarg defense-in-depth;
|
||||
the DDB item has `password_hash`, never `password`. Verified by
|
||||
`tests/test_idp_auth.py:TestNoRawPasswordsInLogs`.
|
||||
|
||||
**Residual risk:** low. Argon2id with the OWASP params is
|
||||
GPU-resistant at scale; the remaining risk is a parameter-weakness
|
||||
advisory (mitigated by the 90-day KMS rotation cadence's analog for
|
||||
hash params — revisit annually).
|
||||
|
||||
### T-2 — PAT theft + max TTL (C-6.2)
|
||||
|
||||
**Threat:** an attacker exfiltrates a PAT (filesystem read of
|
||||
`~/.nova/credentials.json`, a leaked CI env var, a phishing capture)
|
||||
and uses it to vend OIDC tokens until it expires.
|
||||
|
||||
**Mitigations:**
|
||||
- **`~/.nova/credentials.json` stores the OIDC token + PAT metadata
|
||||
(`jti`, `exp`, `type`) ONLY — NOT the raw PAT** (C-7.3). The raw PAT
|
||||
is entered once at `nova auth login` and never persisted. An attacker
|
||||
who reads the credentials file gets a short-lived OIDC token (15 min
|
||||
default), not the long-lived PAT. Verified by
|
||||
`tests/test_auth_commands.py:test_login_stores_oidc_token_not_raw_pat`.
|
||||
- **Max TTL (C-6.2):** developer PATs ≤ 24h (86400s), service-account
|
||||
PATs ≤ 1h (3600s). Enforced in `core.pat_lifecycle.issue_pat` —
|
||||
requests above the max are clamped (with an audit event). The shorter
|
||||
service-account TTL bounds the CI blast radius.
|
||||
- **Revocation via strong-read DDB (D-229):** the token-vend Lambda
|
||||
does `GetItem(PK=jti, ConsistentRead=True)` on `nova-pats` on every
|
||||
vend. A revocation (`status=revoked`) is reflected on the next vend
|
||||
within **60s P95** (the strong read is synchronous — the 60s is the
|
||||
P95 propagation bound, not a polling delay). Verified by
|
||||
`tests/test_pat_revocation.py:test_pat_revocation_slo` (asserts
|
||||
`<1s` locally).
|
||||
- **Emergency revocation at the DDB level** (when the CLI is
|
||||
unavailable): `aws dynamodb update-item --table-name nova-pats ...`
|
||||
flips `status` to `revoked` — see `docs/operator-guide-idp.md` §9.
|
||||
|
||||
**Residual risk:** medium. The PAT is a bearer token — theft is
|
||||
undetectable until the attacker vends a token. Mitigation is TTL
|
||||
bounding + revocation, not prevention. The 1h service-account cap is
|
||||
the primary control for CI exposure.
|
||||
|
||||
### T-3 — JWKS unauthenticated endpoint DDoS (C-6.2)
|
||||
|
||||
**Threat:** the JWKS endpoint (`nova-idp-jwks` function URL,
|
||||
`AuthType: NONE`) is a public, unauthenticated target. An attacker can
|
||||
flood it with requests, exhausting Lambda concurrency and making token
|
||||
verification fail for all clients (a cheap DoS).
|
||||
|
||||
**Mitigations:**
|
||||
- **Reserved concurrency (10, max ~100 RPS):** the JWKS Lambda has a
|
||||
reserved-concurrency limit of 10 (set in the CloudFormation
|
||||
template). This caps the blast radius — a flood saturates the JWKS
|
||||
Lambda but does NOT exhaust the account-wide concurrency pool, so
|
||||
`nova-idp-auth` and `nova-idp-token-vend` keep serving.
|
||||
- **Client-side caching (1h):** the JWKS response carries
|
||||
`Cache-Control: max-age=3600`. Clients (`pyjwt.PyJWK` client) cache
|
||||
the keys for 1h, so a JWKS outage does not immediately break
|
||||
verification — already-cached keys keep working.
|
||||
- **Optional CloudFront + WAF (rate-based rule):** `nova idp setup
|
||||
--apply --public-jwks-domain <fqdn>` fronts the function URL with a
|
||||
CloudFront distribution + a WAF web ACL with a rate-based rule
|
||||
(e.g. block an IP after 2000 req/5min). **For any public deployment,
|
||||
set `--public-jwks-domain`.** Without it the function URL is bare —
|
||||
fine for piloting, exposed for production.
|
||||
|
||||
**Residual risk:** medium. The reserved concurrency bounds the cost
|
||||
but a determined attacker can still keep the JWKS Lambda saturated.
|
||||
The WAF + CloudFront path is the production-grade control. JWKS is
|
||||
inherently public (clients MUST fetch it without auth) — this is a
|
||||
fundamental OIDC property, not a Nova design flaw.
|
||||
|
||||
### T-4 — ABAC bypass (C-6.1 / C-7.1)
|
||||
|
||||
**Threat:** the ABAC policy engine (`kyverno-json` / `kj`) fails to
|
||||
load, crashes, or is misconfigured, and the token-vend Lambda vends a
|
||||
token anyway (fails open). This would bypass the authorization gate —
|
||||
every active PAT gets a token regardless of the policy.
|
||||
|
||||
**Mitigations:**
|
||||
- **Fail-closed (C-6.1/C-7.1 — the grill's #1 finding):** the
|
||||
token-vend Lambda's `_evaluate_abac_fail_closed` returns
|
||||
`(False, [], "", "abac_eval_failed")` if:
|
||||
- `KyvernoJsonEngine.is_configured()` returns `False` (`kj` absent),
|
||||
- `get_engine()` raises (engine registry error),
|
||||
- `evaluate_token_vend_policy()` raises (policy parse error, `kj`
|
||||
runtime error).
|
||||
In all three cases the Lambda returns **403** + an audit event
|
||||
`token.vend.denied` (reason `abac_eval_failed`). **Never fails open.**
|
||||
This is INV-17's runtime guarantee — without it, INV-17 is
|
||||
documentation, not a control.
|
||||
- **Verified by `tests/test_abac_fail_closed.py` (7 tests):**
|
||||
engine-not-configured, evaluate-raises, policy-parse-error, ABAC
|
||||
denies, revoked PAT, unknown PAT, audit-event-emitted-on-denial.
|
||||
- **Policy version in every audit event (D-231):** the git blob SHA of
|
||||
`platform/abac/token-vend.policy` is recorded in every
|
||||
`token.vend.allowed`/`token.vend.denied` event. An auditor can
|
||||
reconstruct which policy version governed each vend.
|
||||
|
||||
**Residual risk:** low (given the fail-closed semantics). The
|
||||
remaining risk is a policy-authoring bug (the policy allows too much)
|
||||
— mitigated by PR review (D-231: Platform Security owns the policy)
|
||||
and the policy-version audit trail.
|
||||
|
||||
### T-5 — KMS signing key compromise
|
||||
|
||||
**Threat:** an attacker gains `kms:Sign` permission on
|
||||
`alias/nova-oidc-signing` and forges OIDC tokens.
|
||||
|
||||
**Mitigations:**
|
||||
- **KMS key policy restricts `kms:Sign` to the token-vend Lambda
|
||||
role.** No other principal (including the operator) can sign. The
|
||||
JWKS Lambda role has `kms:GetPublicKey` only (not `Sign`).
|
||||
- **Key rotation (90 days):** the alias is re-pointed to a new
|
||||
`ECC_NIST_P256` key every 90 days (see
|
||||
`docs/operator-guide-idp.md` §6). The old key stays enabled during
|
||||
the overlap window (≥ max PAT TTL = 24h) so already-issued tokens
|
||||
keep verifying, then is disabled + scheduled for deletion.
|
||||
- **JWKS serves both `kid`s during the overlap window:** the JWKS
|
||||
endpoint lists all keys the alias has pointed at that are still
|
||||
enabled. Clients verify against the `kid` in the token header.
|
||||
|
||||
**Residual risk:** low. KMS key policies are the primary control;
|
||||
rotation bounds the exposure window of a stolen key.
|
||||
|
||||
### T-6 — DER → raw ECDSA signature conversion bug (C-5.2 gotcha)
|
||||
|
||||
**Threat:** KMS `sign()` returns a **DER-encoded** ASN.1 ECDSA
|
||||
signature. JWS (RFC 7515 §3.1.3) requires the **raw** `r‖s`
|
||||
concatenation, each coordinate 32 bytes big-endian (for P-256). If the
|
||||
conversion is wrong (wrong byte order, wrong padding, wrong coordinate
|
||||
length), the resulting JWT will not verify with standard libraries
|
||||
(`pyjwt`, `jose`) — or worse, verifies with a *different* signature
|
||||
than intended (a subtle correctness + security bug).
|
||||
|
||||
This is the **#1 implementation risk** identified in RESEARCH §5. The
|
||||
conversion is in `core/kms_signing.py:der_to_raw_ecdsa`:
|
||||
|
||||
```python
|
||||
r, s = decode_dss_signature(der_sig) # cryptography's ASN.1 parser
|
||||
return r.to_bytes(32, "big") + s.to_bytes(32, "big") # raw r‖s
|
||||
```
|
||||
|
||||
**Mitigations:**
|
||||
- **`decode_dss_signature` from `cryptography`** parses the DER (not a
|
||||
hand-rolled ASN.1 parser — that would be the real risk).
|
||||
- **`to_bytes(32, "big")` zero-pads** each coordinate to exactly 32
|
||||
bytes. A coordinate shorter than 32 bytes (high-order zero bytes)
|
||||
is padded; a coordinate longer than 32 bytes raises `ValueError`
|
||||
(the guard at the top of `der_to_raw_ecdsa`).
|
||||
- **Verified by `tests/test_kms_roundtrip.py` (CAP-037):** sign a JWT
|
||||
via `kms_signing.sign_jwt()` (mock KMS with a test ECC keypair) →
|
||||
fetch JWKS via the JWKS Lambda → verify with `pyjwt` + the JWKS key.
|
||||
The round-trip succeeds only if the DER→raw conversion is
|
||||
byte-correct. This is the regression gate for any change to
|
||||
`kms_signing.py`.
|
||||
|
||||
**Residual risk:** low (given the round-trip test). A KMS-side format
|
||||
change (AWS changes the DER encoding) would break the test loudly.
|
||||
|
||||
### T-7 — No AWS-managed identity (INV-15)
|
||||
|
||||
**Threat:** (architectural invariant, not an attack.) Nova-idp depends
|
||||
on Cognito, IAM Identity Center, or another AWS-managed identity
|
||||
service, creating a vendor lock-in and an opaque trust boundary.
|
||||
|
||||
**Mitigation:**
|
||||
- **INV-15 (no AWS-managed identity in path):** Nova-idp uses **KMS +
|
||||
DDB + Lambda only.** No Cognito, no IAM Identity Center, no managed
|
||||
user pools. The identity layer is greenfield and fully owned by
|
||||
Nova. This is a constraint, not a mitigation — it shapes the whole
|
||||
design (Argon2id in Lambda instead of Cognito user pools; KMS-signed
|
||||
JWTs instead of Cognito issued tokens; DDB `nova-pats` instead of
|
||||
IAM access keys).
|
||||
- **Verified by inspection:** `core/lambda/nova_idp_auth.py` +
|
||||
`nova_idp_token_vend.py` import only `boto3` (DDB + KMS), `argon2`,
|
||||
`cryptography`, `pyjwt`, and `core.*`. No `cognitoidp` or
|
||||
`identitystore` client calls anywhere in the identity layer.
|
||||
|
||||
**Residual risk:** none (this is a satisfied constraint, not a
|
||||
residual). The trade-off is operational burden (Nova runs its own
|
||||
password hashing, token signing, revocation) in exchange for
|
||||
portability and no opaque trust boundary.
|
||||
|
||||
### T-8 — Audit trail integrity
|
||||
|
||||
**Threat:** an attacker tampers with the audit stream to hide a
|
||||
malicious vend, a revocation, or a policy change.
|
||||
|
||||
**Mitigations:**
|
||||
- **Every event emitted (INV-12):** `cli.invocation`, `auth.sign_up`,
|
||||
`auth.sign_in`, `auth.session_created`, `pat.issued`, `pat.revoked`,
|
||||
`token.vend.allowed`, `token.vend.denied`, `auth.login`,
|
||||
`auth.status`, `auth.revoke` — each is a JSON line on stderr with a
|
||||
timestamp + the relevant identifiers (`user_id`, `jti`, `sub`,
|
||||
`policy_sha`).
|
||||
- **Policy version (git SHA, D-231) in every token-vend event:** the
|
||||
`policy_sha` field lets an auditor reconstruct which policy version
|
||||
governed each vend — a policy change is visible in the audit stream
|
||||
as a `policy_sha` change.
|
||||
- **Raw credentials scrubbed (INV-16/INV-17 spirit):** the
|
||||
`_emit_audit` functions in `nova_idp_auth.py`,
|
||||
`nova_idp_token_vend.py`, and `pat_lifecycle.py` pop any
|
||||
`password`/`pat`/`token`/`raw_pat` kwarg defense-in-depth. The audit
|
||||
stream carries identifiers, not secrets.
|
||||
- **Revoked PATs retained (REQ-343):** `nova-pats` rows are marked
|
||||
`status=revoked`, never deleted. The audit trail of "who was
|
||||
revoked, when" is queryable.
|
||||
|
||||
**Residual risk:** medium (audit integrity is only as strong as the
|
||||
log destination). The Lambdas emit to stderr (CloudWatch Logs by
|
||||
default); the integrity guarantee depends on the downstream log
|
||||
pipeline (immutability, retention). For high-assurance deployments,
|
||||
forward the audit stream to an append-only store (S3 Object Lock, a
|
||||
write-once log service). This is a deployment concern, documented in
|
||||
the operator guide.
|
||||
|
||||
---
|
||||
|
||||
## 4. C-9.2 — INV-18..21 compression audit
|
||||
|
||||
The source spec (the v1.28 design document that was re-mapped into this
|
||||
repo's REQ-323..353 / INV-12..17 — see `REQUIREMENTS.md` §v1.28 "ID
|
||||
re-mapping") referenced `INV-18..21` as "attestation invariants."
|
||||
Those IDs **do not exist in this repo** (this repo's invariants run
|
||||
INV-1..11 for the blockchain/pilot work and INV-12..17 for v1.28). The
|
||||
grill (C-9.2) requires an audit verifying the spec's attestation
|
||||
invariant semantics were fully captured by the re-mapped
|
||||
INV-15/INV-16/INV-17 + REQ-332, with no semantic gap.
|
||||
|
||||
### The spec's attestation invariant semantics (reconstructed)
|
||||
|
||||
The source spec's INV-18..21 expressed four attestation concerns:
|
||||
|
||||
1. **Immutability** — an attestation, once made, cannot be silently
|
||||
altered.
|
||||
2. **Signature verifiability** — the attestation's signature can be
|
||||
independently verified by a third party holding the public key.
|
||||
3. **Key derivation** — the signing key is derived from a known input
|
||||
(the PAT) via a specified KDF, not ad-hoc.
|
||||
4. **No AWS-managed identity** — the attestation scheme does not
|
||||
depend on Cognito / IAM Identity Center (the greenfield constraint).
|
||||
|
||||
### Mapping to the re-mapped invariants + requirements
|
||||
|
||||
| Spec concern | Re-mapped to | Where enforced |
|
||||
|--------------|--------------|----------------|
|
||||
| Immutability | **INV-6** (existing, pre-v1.28 — the immutable audit ledger) + **INV-17** (ABAC discipline — every vend is audited with `policy_sha`) | the audit stream is append-only; `policy_sha` binds each vend to a policy version |
|
||||
| Signature verifiability | **REQ-332** (JWS-from-PAT KDF) + **REQ-337** (KMS-signed OIDC, JWKS verifiable) | `core/jws_attestation.py:verify_attestation` (HS256, constant-time compare); `core/kms_signing.py` + JWKS endpoint |
|
||||
| Key derivation | **REQ-332** (C-5.2 grill fix) — `HKDF-SHA256(PAT, salt='nova-local-attestation', info='jws-signing-key')` → 32-byte symmetric key | `core/jws_attestation.py:derive_signing_key`; verified by `tests/test_jws_attestation.py` |
|
||||
| No AWS-managed identity | **INV-15** (no Cognito / IAM Identity Center in path) | inspection — the identity layer uses KMS + DDB + Lambda only |
|
||||
|
||||
### Conclusion: the compression is sound — no semantic gap
|
||||
|
||||
The spec's four attestation concerns are covered by:
|
||||
- **INV-6** (immutability — the existing audit ledger, carried forward
|
||||
from pre-v1.28 milestones),
|
||||
- **INV-15** (no AWS-managed identity — the greenfield constraint),
|
||||
- **INV-16** (password storage — the Argon2id + no-raw-password rule,
|
||||
which is the attestation *input* integrity for signup),
|
||||
- **INV-17** (ABAC discipline — every vend is policy-gated + audited
|
||||
with `policy_sha`),
|
||||
- **REQ-332** (JWS-from-PAT KDF — the signature + key-derivation
|
||||
scheme for local-review attestations).
|
||||
|
||||
The re-mapping from `INV-18..21` → `INV-15/16/17 + REQ-332` is a
|
||||
**compression** (4 invariants → 3 invariants + 1 requirement), not a
|
||||
**drop**. The four original concerns (immutability, signature
|
||||
verifiability, key derivation, no-managed-identity) each have a
|
||||
load-bearing home in the re-mapped set. **No attestation invariant
|
||||
semantics were silently dropped.**
|
||||
|
||||
The compression is *justified* because:
|
||||
- INV-6 already covered audit immutability (re-stating it as INV-18
|
||||
would have been a duplicate of an existing invariant).
|
||||
- INV-15 already covered the no-managed-identity constraint
|
||||
(re-stating it as INV-21 would have been a duplicate).
|
||||
- INV-16 + INV-17 cover the input-integrity + policy-discipline
|
||||
concerns that the spec's INV-19/20 expressed as attestation-specific
|
||||
invariants (they are in fact general identity-layer invariants, not
|
||||
attestation-specific).
|
||||
- REQ-332 carries the signature + KDF detail that the spec's INV-18
|
||||
hand-waved ("public key derivable from the PAT") — and corrects it
|
||||
to a sound symmetric scheme (C-5.2).
|
||||
|
||||
### Audit verification (how to re-run this audit)
|
||||
|
||||
```sh
|
||||
# 1. Confirm INV-18..21 do not exist in this repo.
|
||||
grep -rE 'INV-1[89]|INV-2[01]' .ciagent/ docs/ core/ tests/ \
|
||||
| grep -v 'INV-18..21' # only the C-9.2 audit references should remain
|
||||
|
||||
# 2. Confirm the re-mapped invariants + REQ-332 exist + are tested.
|
||||
pytest tests/test_jws_attestation.py tests/test_abac_fail_closed.py \
|
||||
tests/test_kms_roundtrip.py tests/test_argon2_fail_closed.py -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Test coverage summary
|
||||
|
||||
| Threat | Test file | What it verifies |
|
||||
|--------|-----------|------------------|
|
||||
| T-1 (password) | `tests/test_argon2_fail_closed.py` | 503 on argon2 unavailable (no weak hash) |
|
||||
| T-1 (password) | `tests/test_idp_auth.py` | no raw password in DDB item or logs (INV-16) |
|
||||
| T-2 (PAT theft) | `tests/test_auth_commands.py` | credentials.json has OIDC token, NOT raw PAT (C-7.3) |
|
||||
| T-2 (PAT theft) | `tests/test_pat_revocation.py` | revocation takes effect <1s (D-229 SLO) |
|
||||
| T-3 (JWKS DDoS) | (CloudFormation template inspection) | reserved concurrency = 10; WAF with `--public-jwks-domain` |
|
||||
| T-4 (ABAC bypass) | `tests/test_abac_fail_closed.py` (7 tests) | fail-closed on engine absent / error / deny (C-6.1) |
|
||||
| T-5 (KMS key) | `tests/test_kms_roundtrip.py` | KMS sign → JWKS → pyjwt verify (CAP-037) |
|
||||
| T-6 (DER→raw) | `tests/test_kms_roundtrip.py` | the round-trip succeeds only if DER→raw is byte-correct |
|
||||
| T-7 (no managed id) | (inspection) | no `cognitoidp` / `identitystore` imports in the identity layer |
|
||||
| T-8 (audit) | `tests/test_e2e_idp.py` | the full audit chain is present + linked (REQ-348) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Open items (deferred, not blocking v1.28)
|
||||
|
||||
- **WAF rate-limit tuning:** the default rate-based rule threshold
|
||||
(2000 req/5min/IP) is a pilot-scale guess. Production tuning needs
|
||||
real traffic data. Tracked as a post-v1.28 ops task.
|
||||
- **Audit log forwarding to an append-only store** (S3 Object Lock):
|
||||
the Lambdas emit to stderr / CloudWatch Logs by default. High-
|
||||
assurance deployments should forward to a write-once destination.
|
||||
Documented in the operator guide; not enforced in code.
|
||||
- **PAT theft detection:** there is no anomaly detection on PAT usage
|
||||
(e.g. a vend from a new geography). The TTL + revocation is the
|
||||
control. Detection is a future milestone.
|
||||
@@ -0,0 +1,520 @@
|
||||
"""E2E integration test — sign-up → sign-in → token-vend → apply → audit
|
||||
(REQ-348, J1+J2 happy path combined).
|
||||
|
||||
This is the P5 Wave 2 integration test. It exercises the full Nova-idp
|
||||
identity chain end-to-end against moto (DynamoDB) + a mock KMS (a test
|
||||
ECC keypair). In CI against a deployed Nova-idp it would hit the real
|
||||
Lambdas; locally it uses direct function calls (the dual-use
|
||||
``dispatch_action`` / ``vend_token`` entry points, REQ-329).
|
||||
|
||||
The flow (REQ-348):
|
||||
|
||||
1. sign_up(email, password) → user in nova-users (Argon2id hash)
|
||||
2. sign_in(email, password) → session_id in nova-sessions
|
||||
3. issue a PAT (pat_lifecycle.issue_pat) → raw PAT returned once
|
||||
4. nova auth login (token-vend) → KMS-signed OIDC token
|
||||
5. verify the OIDC token against the JWKS key (pyjwt)
|
||||
6. nova apply --local --sign-local-review → JWS attestation (HS256)
|
||||
7. verify the JWS attestation with the PAT-derived key
|
||||
8. assert the audit chain is complete + linked
|
||||
|
||||
Asserts (a)–(g) from the task spec are mapped to the test methods below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||
os.environ.setdefault("AWS_ACCESS_KEY_ID", "test")
|
||||
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test")
|
||||
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
os.environ.setdefault("NOVA_REPO_ROOT", str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load the Lambda modules via importlib (`lambda` is a Python reserved word
|
||||
# — mirrors tests/test_idp_auth.py / test_token_vend.py).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _load(path: Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
idp_auth = _load(_REPO / "core" / "lambda" / "nova_idp_auth.py", "nova_idp_auth_e2e")
|
||||
token_vend = _load(_REPO / "core" / "lambda" / "nova_idp_token_vend.py", "nova_idp_token_vend_e2e")
|
||||
jwks_mod = _load(_REPO / "core" / "lambda" / "nova_idp_jwks.py", "nova_idp_jwks_e2e")
|
||||
|
||||
import boto3
|
||||
from moto import mock_aws
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
|
||||
import core.kms_signing as kms_signing
|
||||
import core.pat_lifecycle as pat_life
|
||||
import core.jws_attestation as jws_attestation
|
||||
import core.env as env_mod
|
||||
from core.contract_resolver import resolve
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock KMS (a test ECC keypair — same pattern as test_kms_roundtrip.py).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MockKms:
|
||||
def __init__(self, priv, pub_der):
|
||||
self._priv = priv
|
||||
self._pub_der = pub_der
|
||||
|
||||
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||
return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||
|
||||
def get_public_key(self, KeyId):
|
||||
return {"PublicKey": self._pub_der}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table creation (the 4 IdP tables).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_idp_tables(ddb):
|
||||
"""Create the 4 IdP tables (nova-users, nova-sessions,
|
||||
nova-password-resets, nova-pats) with the GSIs the auth + PAT code
|
||||
expects."""
|
||||
ddb.create_table(
|
||||
TableName="nova-users",
|
||||
KeySchema=[{"AttributeName": "user_id", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "user_id", "AttributeType": "S"},
|
||||
{"AttributeName": "email", "AttributeType": "S"},
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{
|
||||
"IndexName": "email-index",
|
||||
"KeySchema": [{"AttributeName": "email", "KeyType": "HASH"}],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
}
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
ddb.create_table(
|
||||
TableName="nova-sessions",
|
||||
KeySchema=[{"AttributeName": "session_id", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "session_id", "AttributeType": "S"},
|
||||
{"AttributeName": "user_id", "AttributeType": "S"},
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{
|
||||
"IndexName": "user_id-index",
|
||||
"KeySchema": [{"AttributeName": "user_id", "KeyType": "HASH"}],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
}
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
ddb.create_table(
|
||||
TableName="nova-password-resets",
|
||||
KeySchema=[{"AttributeName": "reset_token", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[{"AttributeName": "reset_token", "AttributeType": "S"}],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
ddb.create_table(
|
||||
TableName="nova-pats",
|
||||
KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "jti", "AttributeType": "S"},
|
||||
{"AttributeName": "sub", "AttributeType": "S"},
|
||||
{"AttributeName": "pat_hash", "AttributeType": "S"},
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{"IndexName": "sub-index",
|
||||
"KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}],
|
||||
"Projection": {"ProjectionType": "ALL"}},
|
||||
{"IndexName": "pat_hash-index",
|
||||
"KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}],
|
||||
"Projection": {"ProjectionType": "ALL"}},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_keypair():
|
||||
priv = ec.generate_private_key(ec.SECP256R1())
|
||||
pub = priv.public_key()
|
||||
pub_der = pub.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return priv, pub, pub_der
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_modules():
|
||||
"""Reset the cached boto3 singletons + the mock KMS client."""
|
||||
idp_auth._dynamodb = None
|
||||
token_vend._dynamodb = None
|
||||
pat_life._dynamodb = None
|
||||
yield
|
||||
idp_auth._dynamodb = None
|
||||
token_vend._dynamodb = None
|
||||
pat_life._dynamodb = None
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cred_file(tmp_path, monkeypatch):
|
||||
"""Isolate ~/.nova/credentials.json to a tmp path (C-7.3)."""
|
||||
p = tmp_path / "credentials.json"
|
||||
monkeypatch.setenv("NOVA_CREDENTIALS_FILE", str(p))
|
||||
yield p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_contract(tmp_path):
|
||||
"""A minimal contract YAML that resolve() + synthesize_local_env()
|
||||
can consume (mirrors tests/test_local_env.py's fixture)."""
|
||||
contract = """
|
||||
id: msvc
|
||||
name: microservice
|
||||
environment: dev
|
||||
infrastructure:
|
||||
microservice:
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
image: nginx:latest
|
||||
"""
|
||||
p = tmp_path / "contract.yml"
|
||||
p.write_text(contract)
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit-event capture (the Lambdas emit JSON lines on stderr).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _AuditCapture:
|
||||
"""Capture JSON audit lines written to stderr by the Lambda modules.
|
||||
|
||||
Each Lambda's ``_emit_audit`` does ``sys.stderr.write(json + "\\n")``.
|
||||
We replace the module's ``sys`` reference's stderr with a StringIO
|
||||
during the flow, then parse the captured lines back into dicts.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.events: list[dict] = []
|
||||
self._buf = io.StringIO()
|
||||
self._real_stderr = sys.stderr
|
||||
|
||||
def __enter__(self):
|
||||
# Patch sys.stderr globally for the duration — the Lambda modules
|
||||
# all use the module-level `sys` import (sys.stderr.write).
|
||||
sys.stderr = self._buf
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
sys.stderr = self._real_stderr
|
||||
self._buf.seek(0)
|
||||
for line in self._buf.getvalue().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
self.events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
# Non-JSON stderr noise (e.g. a traceback) — ignore.
|
||||
pass
|
||||
return False
|
||||
|
||||
def event_types(self) -> list[str]:
|
||||
return [e.get("event", "") for e in self.events]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The E2E test (REQ-348).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestE2EIdpFlow:
|
||||
"""E2E: sign-up → sign-in → token-vend → apply → audit (REQ-348).
|
||||
|
||||
Runs against moto (DynamoDB) + mock KMS locally; in CI the same
|
||||
assertions run against the deployed Nova-idp Lambdas.
|
||||
"""
|
||||
|
||||
@mock_aws
|
||||
def test_full_e2e_sign_up_sign_in_token_vend_apply_audit(
|
||||
self, test_keypair, cred_file, sample_contract
|
||||
):
|
||||
priv, pub, pub_der = test_keypair
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_idp_tables(ddb)
|
||||
|
||||
email = "alice@example.com"
|
||||
password = "E2E-Secret-12345"
|
||||
owner = "team-a"
|
||||
|
||||
audit = _AuditCapture()
|
||||
with audit:
|
||||
# --- (a) sign_up succeeds ---
|
||||
up = idp_auth.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": email,
|
||||
"password": password,
|
||||
"owner": owner,
|
||||
"roles": ["developer"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert up["statusCode"] == 200, up
|
||||
up_body = json.loads(up["body"])
|
||||
user_id = up_body["user_id"]
|
||||
assert user_id
|
||||
|
||||
# --- (b) sign_in returns a session ---
|
||||
inn = idp_auth.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{"action": "sign_in", "email": email, "password": password}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert inn["statusCode"] == 200, inn
|
||||
session_id = json.loads(inn["body"])["session_id"]
|
||||
assert session_id
|
||||
|
||||
# --- issue a PAT (the developer logs in with it) ---
|
||||
pat = pat_life.issue_pat(
|
||||
user_id, ["developer"], owner, ttl_seconds=3600,
|
||||
subject_type="developer",
|
||||
)
|
||||
assert pat, "no raw PAT returned"
|
||||
# Extract the PAT jti for later audit-link assertions.
|
||||
pat_payload = json.loads(
|
||||
base64.urlsafe_b64decode(pat.split(".")[1] + "==")
|
||||
)
|
||||
pat_jti = pat_payload["jti"]
|
||||
assert pat_jti
|
||||
|
||||
# --- (c) token-vend returns an OIDC token ---
|
||||
vend_body = {
|
||||
"token": pat,
|
||||
"environment": "dev",
|
||||
"requested_claims": ["sub", "roles"],
|
||||
"target_resource": {
|
||||
"type": "contract", "id": "msvc",
|
||||
"owner": owner, "environment": "dev",
|
||||
},
|
||||
}
|
||||
vresp = token_vend.lambda_handler(
|
||||
{"body": json.dumps(vend_body)}, None
|
||||
)
|
||||
assert vresp["statusCode"] == 200, vresp
|
||||
oidc_token = json.loads(vresp["body"])["token"]
|
||||
assert oidc_token
|
||||
|
||||
# --- (d) the OIDC token verifies with the JWKS key ---
|
||||
jwks_resp = jwks_mod.lambda_handler({}, None)
|
||||
assert jwks_resp["statusCode"] == 200, jwks_resp
|
||||
jwk = json.loads(jwks_resp["body"])["keys"][0]
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded_oidc = pyjwt.decode(
|
||||
oidc_token, key, algorithms=["ES256"],
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
assert decoded_oidc["sub"] == user_id
|
||||
assert decoded_oidc["typ"] == "nova_oidc_token"
|
||||
assert decoded_oidc["roles"] == ["developer"]
|
||||
assert "jti" in decoded_oidc and "exp" in decoded_oidc
|
||||
|
||||
# --- store the credential (nova auth login) ---
|
||||
# Use the auth_store directly (login.py's local path calls
|
||||
# token_vend in-process, which we already did above).
|
||||
from core.auth_store import store_credential
|
||||
store_credential(
|
||||
jti=decoded_oidc["jti"],
|
||||
cred_type=decoded_oidc["typ"],
|
||||
exp=decoded_oidc["exp"],
|
||||
oidc_token=oidc_token,
|
||||
)
|
||||
# C-7.3: the credentials file has the OIDC token, NOT the raw PAT.
|
||||
raw_cred = cred_file.read_text()
|
||||
assert "raw_pat" not in raw_cred
|
||||
assert pat not in raw_cred
|
||||
|
||||
# --- (e) nova apply --local --sign-local-review produces a JWS ---
|
||||
# Drive apply via the core functions directly (nova/apply.py's
|
||||
# run() calls these; we skip the argparse layer for the test).
|
||||
synth = env_mod.synthesize_local_env(
|
||||
str(sample_contract), environment="dev"
|
||||
)
|
||||
assert synth["region"] == "local"
|
||||
attestation_payload = {
|
||||
"contract": str(sample_contract),
|
||||
"review": "local",
|
||||
"user_id": user_id,
|
||||
"pat_jti": pat_jti,
|
||||
}
|
||||
jws = jws_attestation.sign_attestation(attestation_payload, pat)
|
||||
assert jws.count(".") == 2, "not a compact JWS (3 segments)"
|
||||
|
||||
# --- (f) the JWS verifies with the PAT-derived key ---
|
||||
verified = jws_attestation.verify_attestation(jws, pat)
|
||||
assert verified == attestation_payload
|
||||
|
||||
# Tamper detection: verify with the wrong PAT raises.
|
||||
with pytest.raises(jws_attestation.JWSValidationError):
|
||||
jws_attestation.verify_attestation(jws, pat + "tampered")
|
||||
|
||||
# --- (g) the audit chain is complete + linked ---
|
||||
# Every step emitted an audit event with the expected event type.
|
||||
types = audit.event_types()
|
||||
# sign_up + sign_in + session_created + pat.issued + token.vend.allowed
|
||||
assert "auth.sign_up" in types, f"missing auth.sign_up in {types}"
|
||||
assert "auth.sign_in" in types, f"missing auth.sign_in in {types}"
|
||||
assert "auth.session_created" in types, f"missing auth.session_created in {types}"
|
||||
assert "pat.issued" in types, f"missing pat.issued in {types}"
|
||||
assert "token.vend.allowed" in types, f"missing token.vend.allowed in {types}"
|
||||
|
||||
# Linkage: the sign_up + sign_in events share the same user_id.
|
||||
sign_up_ev = next(e for e in audit.events if e.get("event") == "auth.sign_up")
|
||||
sign_in_ev = next(e for e in audit.events if e.get("event") == "auth.sign_in")
|
||||
assert sign_up_ev["user_id"] == user_id
|
||||
assert sign_in_ev["user_id"] == user_id
|
||||
assert sign_up_ev["email"] == email
|
||||
|
||||
# Linkage: the pat.issued event carries the PAT jti + sub.
|
||||
pat_issued_ev = next(e for e in audit.events if e.get("event") == "pat.issued")
|
||||
assert pat_issued_ev["jti"] == pat_jti
|
||||
assert pat_issued_ev["sub"] == user_id
|
||||
|
||||
# Linkage: the token.vend.allowed event carries the PAT jti + sub +
|
||||
# policy_sha (D-231).
|
||||
vend_ev = next(e for e in audit.events if e.get("event") == "token.vend.allowed")
|
||||
assert vend_ev["pat_jti"] == pat_jti
|
||||
assert vend_ev["sub"] == user_id
|
||||
assert "policy_sha" in vend_ev
|
||||
|
||||
# Linkage: no raw password / PAT leaked into any audit event (INV-16).
|
||||
for ev in audit.events:
|
||||
blob = json.dumps(ev, sort_keys=True)
|
||||
assert password not in blob, (
|
||||
f"raw password leaked into audit event {ev.get('event')!r}: {blob}"
|
||||
)
|
||||
assert pat not in blob, (
|
||||
f"raw PAT leaked into audit event {ev.get('event')!r}: {blob}"
|
||||
)
|
||||
|
||||
# --- the user item in nova-users has a password_hash, NOT the raw password ---
|
||||
item = ddb.get_item(
|
||||
TableName="nova-users", Key={"user_id": {"S": user_id}}
|
||||
)
|
||||
assert "Item" in item
|
||||
attrs = item["Item"]
|
||||
assert "password_hash" in attrs
|
||||
assert attrs["password_hash"]["S"].startswith("$argon2id$")
|
||||
assert "password" not in attrs, "raw password stored in DDB item!"
|
||||
for key, val in attrs.items():
|
||||
sval = val.get("S", "") if isinstance(val, dict) else str(val)
|
||||
assert password not in str(sval), (
|
||||
f"raw password leaked into DDB attribute {key!r}"
|
||||
)
|
||||
|
||||
# --- the PAT row in nova-pats has a hash, NOT the raw PAT ---
|
||||
pat_item = ddb.get_item(
|
||||
TableName="nova-pats",
|
||||
Key={"jti": {"S": pat_jti}},
|
||||
ConsistentRead=True,
|
||||
)
|
||||
assert "Item" in pat_item
|
||||
assert pat_item["Item"]["status"]["S"] == "active"
|
||||
assert "pat_hash" in pat_item["Item"]
|
||||
raw_pat_blob = json.dumps(pat_item["Item"], sort_keys=True)
|
||||
assert pat not in raw_pat_blob, "raw PAT stored in nova-pats item!"
|
||||
|
||||
@mock_aws
|
||||
def test_e2e_revocation_breaks_the_chain(self, test_keypair, sample_contract):
|
||||
"""The E2E chain breaks at token-vend after revocation (D-229).
|
||||
|
||||
Issue a PAT → revoke it → the next token-vend returns 403
|
||||
pat_revoked (the audit event is token.vend.denied). This is the
|
||||
negative path of the E2E flow — the revocation is the trust
|
||||
anchor, not the JWT signature (D-229).
|
||||
"""
|
||||
priv, _pub, pub_der = test_keypair
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_idp_tables(ddb)
|
||||
|
||||
audit = _AuditCapture()
|
||||
with audit:
|
||||
pat = pat_life.issue_pat(
|
||||
"user-2", ["developer"], "team-b", ttl_seconds=3600,
|
||||
)
|
||||
pat_payload = json.loads(
|
||||
base64.urlsafe_b64decode(pat.split(".")[1] + "==")
|
||||
)
|
||||
pat_jti = pat_payload["jti"]
|
||||
|
||||
# Vend succeeds before revocation.
|
||||
ok = token_vend.lambda_handler(
|
||||
{"body": json.dumps({"token": pat, "environment": "dev"})},
|
||||
None,
|
||||
)
|
||||
assert ok["statusCode"] == 200, ok
|
||||
|
||||
# Revoke.
|
||||
pat_life.revoke_pat(pat_jti)
|
||||
|
||||
# Vend fails after revocation (403 pat_revoked, immediate — D-229).
|
||||
denied = token_vend.lambda_handler(
|
||||
{"body": json.dumps({"token": pat, "environment": "dev"})},
|
||||
None,
|
||||
)
|
||||
assert denied["statusCode"] == 403, denied
|
||||
assert json.loads(denied["body"])["reason"] == "pat_revoked"
|
||||
|
||||
types = audit.event_types()
|
||||
assert "pat.issued" in types
|
||||
assert "pat.revoked" in types
|
||||
assert "token.vend.allowed" in types
|
||||
assert "token.vend.denied" in types
|
||||
|
||||
# The denied event carries the revoked jti + the pat_revoked reason.
|
||||
denied_ev = next(e for e in audit.events if e.get("event") == "token.vend.denied")
|
||||
assert denied_ev["pat_jti"] == pat_jti
|
||||
assert denied_ev["reason"] == "pat_revoked"
|
||||
Reference in New Issue
Block a user