Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa789d703a | |||
| 8c0c2dd268 | |||
| c19cc68d15 | |||
| d247db3569 | |||
| 09253bf0be | |||
| 0789c27ca2 | |||
| 184f33c60a | |||
| a6510e7afc | |||
| c0cb1887ed | |||
| 139cb5077a | |||
| adc55a17ab | |||
| 0d8913a299 | |||
| 4697692ce7 | |||
| 23b8ff81d3 | |||
| d0a8c363b2 | |||
| 04053df16e | |||
| bcbeb7badb | |||
| 1f4f7f0f81 | |||
| df2b83c86b | |||
| f68349d94d | |||
| 1863a85144 | |||
| 7dab9d5756 | |||
| 14809327fb | |||
| 0662ed26a3 | |||
| cd3418a75e | |||
| dee6d88d87 | |||
| fe0ee6aa45 | |||
| 701cc572ce | |||
| 0736924de2 | |||
| 05bf8bf221 | |||
| 7a7fbfed82 | |||
| d06535032c | |||
| 8550ede810 | |||
| 71562d9db2 | |||
| 91cb931bab | |||
| a8ef1e8864 | |||
| 291921a04e | |||
| c9bfc98713 | |||
| ab069db3a4 | |||
| 3338ec1622 | |||
| eb4fade710 | |||
| 5dd7222571 | |||
| 5763e85bb7 | |||
| 37f462783f | |||
| cba7c1c189 | |||
| fd3f9e17b9 | |||
| 03adaa80a6 | |||
| 3a09ca8ec1 | |||
| d7971023b6 | |||
| 6a8267e13f | |||
| 2ed2b3ae0f | |||
| 83883076ff | |||
| 0388751c6e | |||
| 5d1a5f83da | |||
| e7af683af6 | |||
| 939a39743d | |||
| 88e2389a95 | |||
| a0c363c063 | |||
| bbfcbcc4d3 | |||
| e1dc59ba79 | |||
| c629809d75 | |||
| 05efb014d6 | |||
| 9ee1cc8925 | |||
| 48a769ced0 | |||
| 45423c33ae |
+146
-1
@@ -576,4 +576,149 @@ key only after the new one propagates to the consumer's Actions secret
|
||||
store, verified by a post-PUT GET; on upload/verify failure the old key is
|
||||
left Active and the run exits non-zero. The synced workflow file is
|
||||
forge-agnostic (REQ-230): forge base URL / owner / consumer repo come from
|
||||
repository secrets (`NOVA_FORGE_*`, `NOVA_CONSUMER_REPO`), not literals.
|
||||
repository secrets (`NOVA_FORGE_*`, `NOVA_CONSUMER_REPO`), not literals.
|
||||
|
||||
### §12.10 — Nova-idp Identity Layer (v1.28, current)
|
||||
|
||||
Nova owns its identity layer end-to-end. Two (optionally three) Lambda
|
||||
functions + four DynamoDB tables + one KMS asymmetric signing key + one
|
||||
kyverno-json ABAC policy. **No Cognito, no IAM Identity Center (INV-15).**
|
||||
The `nova-cli` Lambda layer carries the Nova wheel + `argon2-cffi` +
|
||||
`cryptography` + `pyjwt` + the `kj` Go binary, making the same code
|
||||
importable in both the CLI and the Lambda (REQ-329 dual-use, NFR-7).
|
||||
|
||||
**Components:**
|
||||
|
||||
- `nova-idp-auth` Lambda — sign-up, sign-in, session creation. Argon2id
|
||||
password hashing (D-228: bundled abi3 wheel; fail-closed on
|
||||
`ImportError`, no pure-Python fallback). DynamoDB: `nova-users`
|
||||
(PK `user_id`, Argon2id `password_hash`), `nova-sessions` (PK
|
||||
`session_id`, TTL `expires_at`), `nova-password-resets` (PK
|
||||
`reset_token`, TTL 15m). Function URL with IAM auth.
|
||||
- `nova-idp-token-vend` Lambda — accepts a PAT (or session token),
|
||||
validates revocation (`nova-pats.GetItem(jti, ConsistentRead=True)` —
|
||||
D-229, 60s SLO), evaluates the kyverno-json ABAC policy at
|
||||
`platform/abac/token-vend.policy` (D-227, INV-17), KMS-signs an
|
||||
ECDSA P-256 JWT (`ES256`), converts DER→raw ECDSA signature (RFC 7515
|
||||
§3.1.3), returns the OIDC token. The `policy_version` (git SHA,
|
||||
D-231) is recorded in every `token.vend.allowed/denied` audit event.
|
||||
- `nova-idp-jwks` Lambda (optional, separation of concerns) — function
|
||||
URL with `AuthType: NONE` (public key only), `Cache-Control: max-age=3600`.
|
||||
`kms.get_public_key` → DER SPKI → JWK via `cryptography`. Custom
|
||||
domain + WAF via CloudFront is OPTIONAL (`--public-jwks-domain` flag
|
||||
on `nova idp setup`, D-230).
|
||||
- `nova-pats` DynamoDB table — PK `jti`, GSI1 `sub` (list PATs for
|
||||
user), GSI2 `pat_hash` (lookup by hash). Only the hash stored (not
|
||||
raw PAT, REQ-343). Revoked PATs retained for audit.
|
||||
|
||||
**CLI surface (`nova` package, greenfield):**
|
||||
|
||||
- Entry point: `[project.scripts] nova = "nova.cli:main"` (argparse-only,
|
||||
no click/typer — repo convention). `nova/cli.py` auto-discovers
|
||||
`nova/<module>.py` subcommands via `pkgutil.iter_modules`, dispatches,
|
||||
emits the `cli.invocation` audit event (INV-12) with `mode`,
|
||||
`selection_reason`, `credential_type`, `command`, `args`.
|
||||
- Each `nova/<module>.py` is ≤50 lines, delegates to `core/` (CAP-034
|
||||
AST scan). Subgroups: `nova auth login/revoke/status`, `nova idp
|
||||
setup --check/--apply/--verify`, `nova init`, `nova apply --local`.
|
||||
- `core/mode_resolver.py` — flag → env (`NOVA_CLIENT_MODE`) → credential
|
||||
type → `sys.stdin.isatty()` (D-226). CLI-only; Lambdas don't resolve
|
||||
modes. Property-tested with `hypothesis` (REQ-349).
|
||||
- `core/env.py:+synthesize_local_env()` — synthesizes a local env dict
|
||||
from a contract + `--local` flag (REQ-330). No cloud provisioning.
|
||||
|
||||
**Packaging (NFR-6, CAP-035):**
|
||||
|
||||
- CI publishes a wheel to CodeArtifact AND a Lambda layer with identical
|
||||
version strings on every merge affecting `core/`/`adapters/`/`nova/`.
|
||||
Version mapping recorded in SSM `/nova/layer/nova-cli/version`.
|
||||
If either publish fails, the merge is blocked (REQ-323).
|
||||
- `nova cli-action` composite action at
|
||||
`.github/actions/nova-cli/action.yml`, referenced by both GitHub +
|
||||
Gitea (`uses: continuous-intelligence/acdl/.github/actions/nova-cli@v1.28`).
|
||||
Python 3.12 pinned. Byte-identical behavior verified by CI matrix
|
||||
(REQ-326, NFR-11).
|
||||
|
||||
**Data flows:**
|
||||
|
||||
1. Sign-up → `nova-idp-auth` → Argon2id → `nova-users` PutItem → session
|
||||
→ `nova-sessions` PutItem → return session token.
|
||||
2. Token vend (hot path) → `nova-idp-token-vend` → `nova-pats` strong
|
||||
read (revocation) → kyverno-json ABAC eval → if allow → KMS sign →
|
||||
DER→raw → return OIDC JWT. Audit at every step.
|
||||
3. JWKS fetch → `nova-idp-jwks` → `kms.get_public_key` → DER→JWK →
|
||||
`{"keys":[...]}`. Cached 1h at CloudFront (if custom domain) / client.
|
||||
4. PAT revoke → `nova auth revoke --pat <jti>` → `nova-pats.UpdateItem(
|
||||
status=revoked)` → audit. Strong read on next vend → 403 (within 60s).
|
||||
|
||||
**`nova idp setup` (REQ-340, NFR-10):** generates a CloudFormation
|
||||
template (raw dict → JSON, no troposphere dep), presents for review
|
||||
(`$PAGER` + resource summary), requires explicit `y/N` approval before
|
||||
`cloudformation deploy --capabilities CAPABILITY_IAM`. `--check` reports
|
||||
prerequisites + IAM policy delta; `--verify` runs the KMS round-trip
|
||||
test. New IAM grants required: `cloudformation:*`, `codeartifact:*`.
|
||||
|
||||
### §12.11 — Platform Ops Reposplit (v1.29, current)
|
||||
|
||||
Platform operations are a Terraform-controlled discipline that lives
|
||||
outside the engineering repo, grounded in Vision §4 (Domain
|
||||
Boundaries — *the platform begins where the artifact is compiled and
|
||||
ends where it runs in production under operational guardrails*). Two
|
||||
repos, two ownership surfaces:
|
||||
|
||||
- **`acdl/acdl` (GitHub)** — engineering. Authors `publish.yml` + the
|
||||
artifacts (Lambda zip, layer wheel, Python wheel, ECR container
|
||||
image with the static `kj` binary). Each tag `v1.29.x` produces a
|
||||
GitHub Release with SHA-256-verified artifacts (REQ-354, D-235
|
||||
tag-pin handoff). Engineering ends at the compiled artifact.
|
||||
- **`nova-platform-ops` (Gitea-private, OPER-PRIV, REQ-359)** —
|
||||
operations. Authors the Terraform modules
|
||||
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`)
|
||||
that bring those artifacts live in `581513795199`. Operations begins
|
||||
at the live platform under guardrails. No GitHub mirror; CIAgent has
|
||||
no presence there.
|
||||
|
||||
The handoff between the two repos is the **tag-pin** (D-235):
|
||||
`nova-platform-ops` declares `local.nova_platform_version` +
|
||||
`local.kj_source_sha` and resolves substrates through a single
|
||||
`data.aws_ecr_image.kj_image`.
|
||||
|
||||
**The `kj` substrate (KJ-LOCKSTEP, REQ-371):** `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 the production Lambda runtime
|
||||
(`aws_lambda_function.nova_idp_token_vend.image_uri`) and its
|
||||
defensive Fargate fallback
|
||||
(`aws_ecs_task_definition.kj.container_definitions[0].image`). A
|
||||
`lifecycle.precondition` on both image-bearing resources enforces at
|
||||
every `terraform plan` that both `image_uri` attributes resolve to the
|
||||
same digest via `data.aws_ecr_image.kj_image`. No second pipeline, no
|
||||
second SHA pin (D-238). KJ-STATIC: the binary is compiled
|
||||
`CGO_ENABLED=0` and `file(1)` reports `statically linked, no shared
|
||||
library` before embedding.
|
||||
|
||||
**Covered-reference REQ tracking pattern:** the 14 covered-reference
|
||||
REQs (355-366, 371) are authored in `nova-platform-ops` (out-of-band).
|
||||
CIAgent in `acdl` tracks them for milestone completeness; their
|
||||
verification surface is the M1/M1.5/M2 cutover gates documented in
|
||||
the operator guide. The operator guide lists each covered-reference
|
||||
REQ with its gate entry + verification command + a "Result" column
|
||||
that the operator attests after running the gate in
|
||||
`nova-platform-ops` CI. P6 audit verifies every covered-reference REQ
|
||||
has a non-empty, green Result (grill CF-2/G-5). M1.5 green (3
|
||||
consecutive rebuilds of the 12-item spike, operator-attested in the
|
||||
guide) is the HARD P6 ship gate (grill CF-1/G-2.1).
|
||||
|
||||
**Operator guide pointer:** `docs/operator-guide-platform-ops.md`
|
||||
(REQ-OPS-GUIDE) — the operator-facing runbook covering the Day-0
|
||||
cutover, M1.5 verification gate, M2 handoff loop, rollback, KMS
|
||||
rotation, JWKS reachability via CloudFront edge (INV-18), PITR
|
||||
restore, PAT revocation, edge config, Fargate standby health, cost,
|
||||
artifact-mirror fallback, and the cutover gates table.
|
||||
|
||||
**JWKS edge (INV-18, D-233):** the JWKS endpoint is the only public
|
||||
read surface of the live platform. CloudFront + OAC pinning
|
||||
(`AuthType: AWS_IAM` on the Function URL — NOT `NONE`,
|
||||
`OriginAccessControlOriginType: lambda`, `SigningBehavior: always`)
|
||||
replaces direct Lambda Function URL exposure. Direct Function URL →
|
||||
403; via-CloudFront → 200.
|
||||
+20
-14
@@ -1,19 +1,25 @@
|
||||
{
|
||||
"phase": 3,
|
||||
"stage": "complete",
|
||||
"milestone": "v1.27",
|
||||
"phase_role": "final",
|
||||
"phase": 4,
|
||||
"stage": "verify",
|
||||
"milestone": "v1.29",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-19T05:30:00Z",
|
||||
"updated_at": "2026-08-20T01:30:00Z",
|
||||
"project": "acdl",
|
||||
"projects": ["acdl", "nova-blockchain-exchange"],
|
||||
"active_milestone": "v1.27",
|
||||
"milestone_branch": "milestone/v1.27-po-state-catalog",
|
||||
"phase_branch": "phase/03-final-review-ship",
|
||||
"tag_line": "v1.26.x",
|
||||
"current_phase": {"phase": 3, "tag": "v1.26.3", "status": "complete"},
|
||||
"previous_phase": {"phase": 2, "tag": "v1.26.2", "status": "complete"},
|
||||
"milestone_complete": true,
|
||||
"milestone_release": {"tag": "v1.26.3", "type": "nfr"},
|
||||
"notes": "v1.27 COMPLETE. NFR milestone (PO State Catalog & Ciagent Compression). 3 phases. STATE.md authored (32 CAPs, 11 invariants, 10 domains). 8 files archived (7 platform + 1 consumer). PROJECT.md + ROADMAP.md v1.26 phase-status fixed. STATE.md wired into P-final ship discipline. Review: 0 P0. Audit: reconstruction PASS, file discipline CLEAN, branch hygiene CLEAN, commit discipline CLEAN (13/13 ---ci--- blocks). Next run starts a new milestone."
|
||||
"active_milestone": "v1.29",
|
||||
"milestone_branch": "milestone/v1.29-reposplit-identity",
|
||||
"phase_branch": "phase/04-operator-guide-reference-tracking",
|
||||
"tag_line": "v1.28.x",
|
||||
"phase_name": "operator-guide-reference-tracking",
|
||||
"milestone_type": "feature",
|
||||
"reqs_covered": ["REQ-354", "REQ-367", "REQ-368", "REQ-369", "REQ-OPS-GUIDE"],
|
||||
"reqs_partial": [],
|
||||
"verification": {
|
||||
"structural": "PASS (746-line operator guide with 25 sections, ARCHITECTURE §12.11 added, STATE.md updated)",
|
||||
"behavioral": "PASS (all 18 required sections present, Cutover Gates table has 14 covered-reference REQs with Result column)",
|
||||
"security": "PASS (KMS rotation, JWKS-EDGE-ONLY, IAM-NARROW, TFM-HITL, PAT revocation all documented)",
|
||||
"quality": "PASS (CAP-039/040/041 added to STATE.md, INV-18 + 10 NFR constraints documented, covered-reference REQs marked with cutover gates)"
|
||||
},
|
||||
"notes": "v1.29 P4 EXECUTE+VERIFY complete. operator-guide-platform-ops.md (746 lines, 18 sections + Cutover Gates table). ARCHITECTURE.md §12.11 (Platform Ops Reposplit). STATE.md: CAP-039/040/041, INV-18, 10 NFR constraints, Domain 12. REQUIREMENTS.md: covered-reference REQs marked with M1/M1.5/M2 gates."
|
||||
}
|
||||
+456
-139
@@ -1,183 +1,500 @@
|
||||
# CLARIFY — v1.27 PO State Catalog & Ciagent Compression
|
||||
# CLARIFY — v1.28 CLI Canonicalization + Identity Layer
|
||||
|
||||
> **Autonomy:** full. Auto-resolution with assumption logging per
|
||||
> `config.autonomy.level: "full"`. No human escalation unless
|
||||
> confidence < 0.60. The prior conversation resolved all material
|
||||
> ambiguities (4 user-answered questions). This file records the
|
||||
> assumptions for the v1.27 record.
|
||||
> `config.autonomy.level: "full"`. No human escalation unless confidence
|
||||
> < 0.60. The user-approved re-mapping plan (v1.18 spec → v1.28) resolved
|
||||
> the headline discrepancy. This file records the remaining ambiguities
|
||||
> and the grounding gaps surfaced in pre-flight.
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
The clarify stage identifies ambiguities in the v1.27 specification
|
||||
and resolves them at full autonomy. The v1.27 spec is the user-approved
|
||||
plan from the prior conversation + the STATE.md design locked by 4
|
||||
question answers. Each ambiguity gets a decision ID (D-214+; continuing
|
||||
from the v1.26 decisions D-200..D-213), a resolution, a confidence
|
||||
score, and a rationale.
|
||||
The clarify stage identifies ambiguities in the v1.28 specification and
|
||||
resolves them at full autonomy. The v1.28 spec is the user-provided
|
||||
"Universal Feature Specification — v1.18 CLI Canonicalization + Identity
|
||||
Layer," re-mapped to v1.28 (milestone number, tag line, and all
|
||||
ID namespaces) per the user-approved plan. Each ambiguity gets a
|
||||
decision ID (D-226+, continuing from v1.27's D-214..D-225), a resolution,
|
||||
a confidence score, and a rationale.
|
||||
|
||||
---
|
||||
|
||||
## Prior-conversation resolutions (already locked, restated for the record)
|
||||
|
||||
These were resolved by user-answered questions in the conversation that
|
||||
spawned v1.27. They are load-bearing for v1.27 execution and cited
|
||||
here so the v1.27 record is self-contained.
|
||||
These were resolved by the user-approved re-mapping plan in the
|
||||
conversation that spawned v1.28. They are load-bearing for v1.28
|
||||
execution.
|
||||
|
||||
### Q-P1 — What should the new PO-reference file catalog?
|
||||
### Q-P1 — The source spec is titled "v1.18" but v1.18 already shipped. What milestone is this?
|
||||
|
||||
**Resolution:** Capability catalog (what the system can do today).
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-214.
|
||||
**Resolution:** Re-map the spec's *content* (CLI Canonicalization +
|
||||
Identity Layer) to **v1.28**, the next milestone after v1.27 (complete).
|
||||
Tags run on the **v1.27.x** line (P0 = `v1.27.0`). Milestone branch:
|
||||
`milestone/v1.28-cli-identity`.
|
||||
**Confidence:** 1.0 (user-confirmed — "Re-map to v1.28"). **Decision:** n/a (milestone identity, not a D-ID).
|
||||
|
||||
### Q-P2 — How should the new file relate to CAPABILITY_INVENTORY.md?
|
||||
### Q-P2 — The spec's "locked inputs" (D-NEW-26, kj engine, Nova-idp, INV-63/64/65, CAP-025..030, REQ-001..031) don't exist in the repo. How to handle?
|
||||
|
||||
**Resolution:** Call it `STATE.md`. PO-owned, ciagent-updated after
|
||||
milestone implementation. CAPABILITY_INVENTORY.md is archived.
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-215.
|
||||
**Resolution:** Author them fresh in this milestone's CLARIFY/RESEARCH as
|
||||
**D-226..D-231, INV-12..17, CAP-033..038, REQ-323..353**. The `kj` engine
|
||||
is mapped to the existing **kyverno-json** engine (INV-4 swappable) — no
|
||||
new engine is built. CAP/INV/REQ IDs are re-allocated to avoid collisions
|
||||
with shipped history (CAP-025..032 and INV-1..11 are blockchain/pilot).
|
||||
**Confidence:** 1.0 (user-confirmed — "Re-map to v1.28"). **Decision:** D-227 (kj→kyverno-json), plus the ID-allocation block in REQUIREMENTS.md.
|
||||
|
||||
### Q-P3 — Where should the file live, and who owns it?
|
||||
### Q-P3 — The spec claims a "Cognito drop." No Cognito exists in the repo. What does NFR-5 mean?
|
||||
|
||||
**Resolution:** Owned by the PO, updated by ciagent after the milestone
|
||||
is implemented with additives.
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-216.
|
||||
|
||||
### Q-P4 — How should "additive when new features are implemented" be enforced?
|
||||
|
||||
**Resolution:** On the last phase / milestone ship (the P-final Wave 3
|
||||
"milestone ship" step). No regression-gate check in this pass.
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-217.
|
||||
|
||||
### Q-P5 — Should the initial STATE.md backfill all shipped capabilities through v1.26?
|
||||
|
||||
**Resolution:** Backfill all shipped capabilities through v1.26
|
||||
(compressed one-liners for v1.1–v1.24; full entries for v1.25 + v1.26).
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-218.
|
||||
|
||||
### Q-P6 — Should the v1.26 pre-execution artifacts (CLARIFY, GRILL, IDEATE, RESEARCH) be archived?
|
||||
|
||||
**Resolution:** Archive all 4 to `.ciagent/archive/` with `-v1.26`
|
||||
suffixes. The next milestone's P0 writes fresh versions. Decisions are
|
||||
already folded into PROJECT.md load-bearing decisions + PLAN.md
|
||||
binding revisions.
|
||||
**Confidence:** 1.0 (user-confirmed). **Decision:** D-219.
|
||||
**Resolution:** NFR-5 (no AWS-managed identity in the path) is a
|
||||
**greenfield constraint**, not a migration. Nova-idp is built fresh; no
|
||||
Cognito/IAM Identity Center is *introduced*. The "drop" framing is
|
||||
aspirational language from the source spec, not a literal removal.
|
||||
**Confidence:** 1.0. **Decision:** D-226 (recorded below; NFR-5 restated
|
||||
as a greenfield constraint in INV-15).
|
||||
|
||||
---
|
||||
|
||||
## Ambiguities + Resolutions (this CLARIFY pass)
|
||||
## Open questions from the spec's §7 (auto-resolved at full autonomy)
|
||||
|
||||
### Q1 — Is v1.27 a feature milestone or an NFR milestone?
|
||||
### Q1 — Argon2 native dependency in Lambda runtime
|
||||
|
||||
**Ambiguity:** v1.27 authors `STATE.md` (a new file/capability for the
|
||||
PO) and archives 11 files. Does the new-file authoring count as `feat:`
|
||||
(making this a feature milestone, tags on v1.26.x with progressive
|
||||
patches) or `docs:`/`chore:` (NFR milestone, same tag behavior but
|
||||
subject to the NFR purity gate)?
|
||||
`argon2-cffi` has a C extension that may not build cleanly in the Lambda
|
||||
Python 3.12 runtime.
|
||||
|
||||
**Resolution:** NFR milestone. `STATE.md` is documentation (a catalog of
|
||||
existing capabilities), not a new platform capability. The archive moves
|
||||
are `chore:` (file relocation, lossless). No code, no schema, no
|
||||
platform behavior change. Tags run on the v1.26.x patch line:
|
||||
`v1.26.0` (P0) → `v1.26.1..v1.26.3` (P1..P3). The final phase's patch
|
||||
(`v1.26.3`) IS the milestone release.
|
||||
**Resolution (D-228):** Use `argon2-cffi` with bundled wheels; if the
|
||||
extension fails to load, fall back to the pure-Python implementation. If
|
||||
both fail, document the Fargate migration path for the auth Lambda.
|
||||
CAP-036 covers end-to-end verification.
|
||||
**Confidence:** 0.85. **Rationale:** Bundled wheels are the standard
|
||||
workaround for Lambda native deps; the pure-Python fallback is a safe
|
||||
degradation. Fargate is the escape hatch if Lambda's runtime is
|
||||
fundamentally incompatible. RESEARCH will validate wheel availability for
|
||||
Python 3.12 + the Lambda execution environment.
|
||||
**Impact if wrong:** Auth Lambda migrates to Fargate, adding ~1 week to P2.
|
||||
|
||||
**Confidence:** 0.95. **Decision:** D-220.
|
||||
### Q2 — PAT revocation propagation latency
|
||||
|
||||
### Q2 — Where does the consumer-side archive (nova-blockchain-exchange/ROADMAP.md) land?
|
||||
The 60-second SLO (NFR-4) depends on whether the token-vend Lambda reads
|
||||
PAT revocation state from DynamoDB on every request (eventually
|
||||
consistent reads) or via a cached/denylist mechanism.
|
||||
|
||||
**Ambiguity:** The platform archive convention is
|
||||
`.ciagent/archive/<file>-<milestone>.md`. The consumer subproject
|
||||
(`nova-blockchain-exchange/`) has no `archive/` subdirectory. Does the
|
||||
consumer ROADMAP archive at `.ciagent/archive/` (platform-side, mixed)
|
||||
or `.ciagent/nova-blockchain-exchange/archive/` (consumer-side, new
|
||||
subdir)?
|
||||
**Resolution (D-229):** Read-on-every-request with strongly consistent
|
||||
reads on the PAT hash table. Cost is acceptable given expected request
|
||||
volume (token vending is not a hot path — it precedes a deploy, not every
|
||||
request). REV-351 verifies the SLO in CI.
|
||||
**Confidence:** 0.90. **Rationale:** Strongly consistent DynamoDB reads
|
||||
have single-digit-ms latency at expected volume; the 60s SLO has >10x
|
||||
headroom. A cache layer adds invalidation complexity that the SLO does
|
||||
not require.
|
||||
**Impact if wrong:** If read latency exceeds 60s under load, introduce a
|
||||
DynamoDB TTL + cache layer; SLO must be re-verified.
|
||||
|
||||
**Resolution:** Consumer-side. Create
|
||||
`.ciagent/nova-blockchain-exchange/archive/` and relocate to
|
||||
`ROADMAP-v1.26.md`. This preserves the per-project path convention
|
||||
(multi-project mode: `.ciagent/<slug>/` paths). The platform archive
|
||||
directory is not mixed with consumer archives.
|
||||
### Q3 — JWKS endpoint: Lambda function URL vs. API Gateway
|
||||
|
||||
**Confidence:** 0.92. **Decision:** D-221.
|
||||
A function URL is simpler and cheaper but lacks throttling, WAF, and
|
||||
custom domains out of the box.
|
||||
|
||||
### Q3 — Does archiving CLARIFY/GRILL/IDEATE/RESEARCH lose the "how v1.26 was specified" traceability?
|
||||
**Resolution (D-230):** Start with a Lambda function URL behind a custom
|
||||
domain; rate limiting configured at the DNS/CDN layer. API Gateway
|
||||
migration deferred to v1.19+ if throttling requirements grow.
|
||||
**Confidence:** 0.80. **Rationale:** The JWKS endpoint is public-key
|
||||
only (no secrets); the threat surface is low. Function URL + CDN rate-
|
||||
limiting covers the v1.28 volume. API Gateway is over-engineering until
|
||||
traffic patterns are known.
|
||||
**Impact if wrong:** If throttling becomes a requirement, API Gateway
|
||||
migration adds ~3-5 days.
|
||||
|
||||
**Ambiguity:** The pre-execution artifacts document the v1.26 decision
|
||||
path. Archiving them moves them out of active context. Is the
|
||||
traceability preserved?
|
||||
### Q4 — Mode resolver precedence with invalid `NOVA_CLIENT_MODE` value
|
||||
|
||||
**Resolution:** Yes. Three layers preserve it: (1) the archive files
|
||||
are byte-identical relocations inside `.ciagent/archive/` (reachable by
|
||||
agents + git history); (2) the decisions D-200..D-213 are folded into
|
||||
`PROJECT.md` load-bearing decisions (the durable record); (3) git
|
||||
history at the v1.26 commits preserves the authoritative state. The
|
||||
active-context reduction is the point — v1.26 is shipped; the next P0
|
||||
writes fresh CLARIFY/GRILL/IDEATE/RESEARCH.
|
||||
What happens if the env var is set to something other than `agent` or
|
||||
`interactive` (e.g., `NOVA_CLIENT_MODE=auto`)?
|
||||
|
||||
**Confidence:** 0.95. **Decision:** D-222.
|
||||
**Resolution (D-226):** Invalid env var values are ignored, falling
|
||||
through to credential type. A warning is logged. Behavior is documented
|
||||
in the `nova-cli` README. This is a sub-clause of the mode-resolution
|
||||
priority decision.
|
||||
**Confidence:** 0.90. **Rationale:** Ignoring + warning is the least
|
||||
surprising behavior for an operator debugging mode issues. Failing hard
|
||||
would block legitimate workflows that set a stale/typo'd env var.
|
||||
**Impact if wrong:** Operators debugging mode issues may be confused;
|
||||
non-blocking.
|
||||
|
||||
### Q4 — Should IAM_POLICY.md be archived (it predates v1.26 and is dated v1.11)?
|
||||
### Q5 — Service-account PAT vs. developer PAT in the same session
|
||||
|
||||
**Ambiguity:** `IAM_POLICY.md` is dated v1.11 (2026-07-28). It predates
|
||||
v1.26 by 5 milestones. The D-207 future key-split (P1+ R-3 in
|
||||
REVIEW-AUDIT-P05) is pending. Archive or keep?
|
||||
What if both credential types are available (e.g., a developer explicitly
|
||||
exports a service-account PAT)?
|
||||
|
||||
**Resolution:** Keep active. `IAM_POLICY.md` is a live baseline —
|
||||
referenced by the regression gate
|
||||
(`tests/test_iam_policy_baseline.py`), enforced by a managed policy on
|
||||
account `581513795199`, and the D-207 key-split is a pending future-
|
||||
hardening item. It is not stale; it is a baseline that grows when
|
||||
grants change. The v1.11 date reflects the last grant addition, not
|
||||
staleness.
|
||||
**Resolution (D-226):** The most recently acquired credential wins.
|
||||
Documented in `nova auth login` output. The credential type is what
|
||||
drives mode resolution (INV-14), so the operator sees which mode was
|
||||
selected and why.
|
||||
**Confidence:** 0.85. **Rationale:** "Most recent wins" is the simplest
|
||||
deterministic rule that matches operator mental models of "I just logged
|
||||
in as X." The audit event records the winning credential type, so the
|
||||
selection is traceable.
|
||||
**Impact if wrong:** Mode selection may surprise the operator; non-
|
||||
blocking, but `nova auth status` must make the active credential explicit.
|
||||
|
||||
**Confidence:** 0.90. **Decision:** D-223.
|
||||
### Q6 — ABAC policy ownership and versioning
|
||||
|
||||
### Q5 — Should REGRESSION_REPORT.{json,md} be refreshed as part of v1.27?
|
||||
`platform/abac/token-vend.policy` is referenced, but who owns changes?
|
||||
How are policy versions tracked in audit?
|
||||
|
||||
**Ambiguity:** Both files are dated 2026-08-01 (v1.10 Phase 52), show
|
||||
CAP-025 absent, and mark live-aws CAPs "Skipped" (state bucket absent
|
||||
pre-v1.26 re-bootstrap). They are stale. Should v1.27 refresh them?
|
||||
|
||||
**Resolution:** No. Both files are machine-managed — written by
|
||||
`core/regression_verify.py:704-705` on every `run_regression.sh` run.
|
||||
They regenerate on the next regression run. v1.27 is docs/chore only
|
||||
(no code); touching machine-managed files by hand creates a drift
|
||||
source. The stale state is honest (the last gate run was v1.10; the
|
||||
next run regenerates). The STATE.md Domain 7 row "Regression gate"
|
||||
notes the current CAP range (CAP-001..025).
|
||||
|
||||
**Confidence:** 0.88. **Decision:** D-224.
|
||||
|
||||
### Q6 — Does PROJECT.md get the v1.26 phase-status fix in v1.27 P1 or P2?
|
||||
|
||||
**Ambiguity:** The plan splits work into P1 (author + archive) and P2
|
||||
(fix stale + wire). The PROJECT.md phase-status fix (P3/P4/P5 pending
|
||||
→ complete) is a "fix stale" item. P1 or P2?
|
||||
|
||||
**Resolution:** P2. P1 is the additive authoring + lossless archive
|
||||
moves. P2 is the corrections to kept files + the ship-discipline wiring.
|
||||
This keeps P1 a pure-additive, no-edit phase (easier review + audit) and
|
||||
P2 the correction phase. The PROJECT.md fix is a correction; P2.
|
||||
|
||||
**Confidence:** 0.85. **Decision:** D-225.
|
||||
**Resolution (D-231):** Policy changes require PR review; the policy
|
||||
version (git SHA) is recorded in every token-vend audit event. Owner:
|
||||
Platform Security. The policy file lives in the platform repo at
|
||||
`platform/abac/token-vend.policy` and is reviewed like any other
|
||||
production config.
|
||||
**Confidence:** 0.90. **Rationale:** Git SHA is the natural version
|
||||
identifier for a repo-resident policy; recording it in the audit event
|
||||
makes every allow/deny decision reconstructable to the exact policy text.
|
||||
**Impact if wrong:** Untracked policy changes could lead to unexpected
|
||||
allow/deny decisions in production, undermining audit defensibility.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
## Grounding gaps surfaced in pre-flight (auto-resolved)
|
||||
|
||||
6 prior-conversation resolutions (D-214..D-219, all user-confirmed)
|
||||
+ 6 new ambiguities (D-220..D-225, all auto-resolved at full autonomy,
|
||||
confidence ≥ 0.60). 0 escalations.
|
||||
### G1 — The `kj` engine does not exist; the spec treats it as locked.
|
||||
|
||||
**Key decisions:**
|
||||
- D-220: v1.27 is an NFR milestone (tags on v1.26.x; final patch is the
|
||||
milestone release).
|
||||
- D-221: Consumer archives land in `.ciagent/nova-blockchain-exchange/archive/`.
|
||||
- D-222: Archiving pre-execution artifacts preserves traceability
|
||||
(archive files + PROJECT.md load-bearing decisions + git history).
|
||||
- D-223: IAM_POLICY.md stays active (live baseline, test-enforced,
|
||||
D-207 pending).
|
||||
- D-224: REGRESSION_REPORT.{json,md} regenerate on next
|
||||
`run_regression.sh` (machine-managed; v1.27 is docs/chore only).
|
||||
- D-225: PROJECT.md phase-status fix is P2 (correction phase), not P1
|
||||
(additive phase).
|
||||
**Resolution (D-227):** The token-vend Lambda uses the existing
|
||||
**kyverno-json** engine (INV-4 swappable) as the ABAC evaluator. The
|
||||
policy at `platform/abac/token-vend.policy` is a kyverno-json policy.
|
||||
No new `kj` engine is built in v1.28. If a distinct `kj` engine is
|
||||
desired later, it is a separate research spike (not this milestone).
|
||||
**Confidence:** 0.95. **Rationale:** The repo already has a swappable
|
||||
policy engine (INV-4) implemented as kyverno-json. Building a second
|
||||
engine to do the same job violates the swappable-engine invariant's
|
||||
spirit. kyverno-json's `evaluate` semantics cover the spec's ABAC needs
|
||||
(subject, claims, resource, environment → allow/deny).
|
||||
**Impact if wrong:** If the user actually wants a new `kj` engine, v1.28
|
||||
scope expands significantly (engine design + implementation + migration).
|
||||
This was flagged as caveat #3 in the approved plan; the recommended path
|
||||
(kyverno-json) is locked here.
|
||||
|
||||
### G2 — The spec's INV-18..21, INV-34, INV-63/64/65 don't exist.
|
||||
|
||||
**Resolution:** Re-allocated as **INV-12..INV-17** (see REQUIREMENTS.md
|
||||
§v1.28 Invariants). The 1:1 mapping:
|
||||
- INV-63 (mode observability) → INV-12
|
||||
- INV-64 (mode determinism) → INV-13
|
||||
- INV-65 (credential type encodes role) → INV-14
|
||||
- INV-18..21 (attestation invariants) → INV-15 (no AWS-managed identity),
|
||||
INV-16 (password storage), INV-17 (ABAC discipline). The spec's
|
||||
attestation invariants INV-18..21 are partially covered by existing
|
||||
invariants (INV-6 immutable audit) + INV-17; the JWS-from-PAT behavior
|
||||
(REQ-332) is a requirement, not a separate invariant, in this mapping.
|
||||
- INV-34 (MFA enforcement) → deferred to v1.21+ (out of scope per §2.2);
|
||||
no INV allocated in v1.28.
|
||||
**Confidence:** 0.85. **Rationale:** The mapping preserves the spec's
|
||||
intent without colliding with the repo's INV-1..11. INV-34 (MFA) is
|
||||
explicitly deferred per the spec's own §2.2 out-of-scope table.
|
||||
**Impact if wrong:** If the user wants the exact INV-18..21 semantics as
|
||||
separate invariants, INV-12..17 can be re-numbered; non-blocking.
|
||||
|
||||
### G3 — The spec's CAP-025..030 collide with blockchain/pilot CAPs.
|
||||
|
||||
**Resolution:** Re-allocated as **CAP-033..CAP-038** (see REQUIREMENTS.md
|
||||
§v1.28 + REQ-352). The 1:1 mapping:
|
||||
- CAP-025 (CLI subcommand surface) → CAP-033
|
||||
- CAP-026 (subcommand delegates to core/) → CAP-034
|
||||
- CAP-027 (layer matches wheel) → CAP-035
|
||||
- CAP-028 (Nova-idp auth flow) → CAP-036
|
||||
- CAP-029 (token-vend signs via KMS) → CAP-037
|
||||
- CAP-030 (PAT issuance + revocation) → CAP-038
|
||||
**Confidence:** 1.0. **Rationale:** Existing CAP-025..032 are
|
||||
blockchain/pilot capabilities (STATE.md); re-use would corrupt the
|
||||
capability registry. The re-allocated IDs are the next available.
|
||||
**Impact if wrong:** None — this is a numbering decision, not a semantic
|
||||
one.
|
||||
|
||||
### G4 — The spec's REQ-001..031 collide / don't exist.
|
||||
|
||||
**Resolution:** Re-allocated as **REQ-323..REQ-353** (1:1 with the spec's
|
||||
REQ-001..031). Full text in REQUIREMENTS.md §v1.28. Max existing REQ =
|
||||
REQ-322.
|
||||
**Confidence:** 1.0. **Rationale:** Same as G3 — avoid collision, use
|
||||
next available range.
|
||||
|
||||
### G5 — `platform/abac/`, `nova/` subcommand dir, `nova-idp-*` Lambdas don't exist.
|
||||
|
||||
**Resolution:** These are **greenfield deliverables** of v1.28 execution
|
||||
phases, not pre-existing "locked architectures." RESEARCH will design
|
||||
them; PLAN will sequence them; EXECUTE will build them. The spec's
|
||||
"Operating Principle 1" (incremental delivery) is honored — v1.28 is
|
||||
net-new work.
|
||||
**Confidence:** 1.0. **Rationale:** The spec itself describes these as
|
||||
new ("introducing Nova-idp"). The mis-framing was in calling them
|
||||
"locked" — they are locked in *scope*, not in *prior existence*.
|
||||
**Impact if wrong:** None — this is a framing correction.
|
||||
|
||||
---
|
||||
|
||||
## Decision ledger (v1.28 — D-226..D-231)
|
||||
|
||||
| ID | Title | Confidence | Load-bearing for |
|
||||
|----|-------|------------|------------------|
|
||||
| D-226 | Mode resolution priority + invalid-env + dual-credential | 0.90 | REQ-327, INV-12, INV-13, INV-14 |
|
||||
| D-227 | ABAC engine = kyverno-json (no `kj` engine built) | 0.95 | REQ-336, REQ-339, INV-17, NFR-9 |
|
||||
| D-228 | Argon2id in Lambda: bundled wheels + pure-Python fallback + Fargate path | 0.85 | REQ-333, REQ-334, INV-16, NFR-8 |
|
||||
| D-229 | PAT revocation: strongly-consistent DDB read-on-every-request, 60s SLO | 0.90 | REQ-342, REQ-343, REQ-351, NFR-4 |
|
||||
| D-230 | JWKS endpoint: Lambda function URL + custom domain + CDN rate-limit | 0.80 | REQ-338, NFR-5 |
|
||||
| D-231 | ABAC policy ownership: Platform Security, git SHA in audit | 0.90 | REQ-339, NFR-9 |
|
||||
|
||||
---
|
||||
|
||||
## Assumptions logged (full autonomy, no human escalation)
|
||||
|
||||
1. **CodeArtifact is provisionable** in AWS account `581513795199` (the
|
||||
pilot account). RESEARCH will confirm IAM permissions + repository
|
||||
creation. If not, v1.28 falls back to a private PyPI server or a
|
||||
Gitea-hosted wheel index; the CLI subcommand surface (REQ-324) and
|
||||
identity layer (REQ-333+) are unaffected.
|
||||
2. **Python 3.12** is the target runtime for both the CLI wheel and the
|
||||
Lambda functions (spec §4 REQ-004.3). The repo's current Python
|
||||
version will be confirmed in RESEARCH; if it differs, the CLI pins
|
||||
3.12 and Lambda uses the 3.12 runtime regardless.
|
||||
3. **KMS asymmetric signing** (RSA-2048 or ECDSA P-256) is available in
|
||||
the target account. RESEARCH will confirm. If only symmetric KMS is
|
||||
available, the token-vend Lambda uses symmetric signing + a public-key
|
||||
publication step (less ideal, but functional); INV-15 is unaffected.
|
||||
4. **The Forge action** (REQ-326) is the existing `nova cli-action`
|
||||
pattern, extended to both GitHub and Gitea marketplaces. The repo's
|
||||
current Forge/Gitea workflow conventions (`.gitea/workflows/`,
|
||||
`deploy.yml@v1.25`) are the baseline.
|
||||
5. **MFA/TOTP** code path ships in v1.28 (per spec §2.2) but enforcement
|
||||
for prod/dr is deferred to v1.21+. This is a doc/test-only path in
|
||||
v1.28 — no enforcement gate.
|
||||
|
||||
---
|
||||
|
||||
## CLARIFY complete
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
+347
-129
@@ -1,141 +1,359 @@
|
||||
# GRILL — v1.27 PO State Catalog & Ciagent Compression
|
||||
# GRILL — v1.28 CLI Canonicalization + Identity Layer
|
||||
|
||||
> Adversarial review of the v1.27 SPECIFY + CLARIFY + RESEARCH + PLAN.
|
||||
> The grill red-teams the proposal across feasibility, scope, and the
|
||||
> compression-loss claims. Each challenge gets a binding verdict
|
||||
> (PROCEED / REVISE / ESCALATE). Autonomy: full.
|
||||
|
||||
## Verdict: PROCEED (0.88) — 0 escalations, 1 revision
|
||||
|
||||
The milestone is feasible, scoped, and the compression is lossless. One
|
||||
binding revision (G-Q2) refines the archive list; already captured in
|
||||
PLAN. No work is blocked.
|
||||
> Adversarial review of the v1.28 SPECIFY + CLARIFY + RESEARCH + PLAN.
|
||||
> Griller: ci-griller subagent. Autonomy: full. All 9 axes reviewed;
|
||||
> every claim verified against the live codebase.
|
||||
|
||||
---
|
||||
|
||||
## Challenges
|
||||
## Overall verdict: **PROCEED-WITH-CONDITIONS** · Confidence 0.76
|
||||
|
||||
### G-Q1 — Is archiving AUTONOMY_THESIS.md + COST.md a context loss?
|
||||
The plan is fundamentally sound — architecture correct, re-mapping
|
||||
clean (no ID collisions), technical depth accurate (DER→raw, strong-
|
||||
read revocation, stdin TTY), highest-risk item (kj binary) has a
|
||||
Fargate fallback. Not unfeasible, not over-scoped beyond an agent-driven
|
||||
repo's capacity, not security-broken by design.
|
||||
|
||||
**Challenge:** `AUTONOMY_THESIS.md` is the "autonomy in operations;
|
||||
human at stage gates" thesis — the defensibility brief. `COST.md` is
|
||||
the only AWS cost record. Archiving both moves them out of active
|
||||
context. Does this lose load-bearing content?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.90).
|
||||
- `AUTONOMY_THESIS.md` (65 lines, "Last refined: v1.21") is fully
|
||||
folded into `NORTH_STAR.md` Vision (lines 17–22: "infrastructure
|
||||
operations become visible... human attestation remains required at
|
||||
stage gates") + Anti-Goals #2 ("Not a system that removes humans from
|
||||
accountability"). The thesis is the source; NORTH_STAR is the
|
||||
authoritative durable copy. Archive preserves the v1.21 refinement;
|
||||
active context reads NORTH_STAR.
|
||||
- `COST.md` (106 lines, dated 2026-07-29, "v1.0 → v1.14") predates the
|
||||
v1.26 live pilot. The v1.26 live apply (ECS + ALB + DynamoDB + S3)
|
||||
incurred real costs this snapshot doesn't reflect. Archiving it is
|
||||
honest — a stale cost record misleads. STATE.md Domain 7 notes cost
|
||||
tracking as a capability (pre-apply Infracost grounded; actual-spend
|
||||
CUR deferred D-096). A future cost milestone writes a fresh report.
|
||||
No revision needed.
|
||||
|
||||
### G-Q2 — Does the archive list include the v1.27 P0 pre-execution files by mistake?
|
||||
|
||||
**Challenge:** D-219 (user-confirmed) says "archive all 4 pre-execution
|
||||
artifacts" (CLARIFY/GRILL/IDEATE/RESEARCH). But P0 already overwrote
|
||||
them with v1.27 content. Archiving the v1.27 versions at v1.27 P1 would
|
||||
lose the v1.27 pre-execution narrative (the decisions D-214..D-225, the
|
||||
research inventory, this grill). Is the archive list wrong?
|
||||
|
||||
**Verdict:** REVISE (confidence 0.92). This is a real ambiguity in the
|
||||
plan. The user's D-219 decision was made *before* P0 overwrote the
|
||||
files; the intent was to archive the *v1.26* pre-execution record. The
|
||||
v1.26-era content is preserved in git history (the pre-P0 commits) —
|
||||
the archive directory is not the only preservation layer. PLAN Task 2.1
|
||||
already self-corrected: the final archive list is **7 platform files +
|
||||
1 consumer file = 8 files**, excluding the 4 pre-execution files. The 4
|
||||
v1.27 P0 versions stay active through v1.27; they archive at v1.28 P1
|
||||
if v1.28 happens. The archive README notes the v1.26 pre-execution
|
||||
record is in git history. No further revision needed — the plan self-
|
||||
corrected.
|
||||
|
||||
### G-Q3 — Is the STATE.md backfill accurate enough to be the PO's source of truth?
|
||||
|
||||
**Challenge:** STATE.md has 36 capability rows across 10 domains,
|
||||
backfilled from 8 sources. The PO will read this before writing new
|
||||
REQs. If a row is inaccurate (wrong shipped tag, wrong file path,
|
||||
wrong controlling REQ), the PO could re-spec an existing capability or
|
||||
cite a stale invariant. Is the backfill accurate?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.85). The backfill sources are
|
||||
authoritative: `core/regression_verify.py` (the machine CAP-NNN
|
||||
registry), `modules/registry.json` (the live module catalog),
|
||||
`REQUIREMENTS.md` traceability (the REQ→phase→status record),
|
||||
`CHECKPOINT.json` (shipped tags), `git log` (file paths). The
|
||||
citations are direct (each row cites the controlling REQ + decision
|
||||
ID). The 11 invariants are distilled from PROJECT.md load-bearing
|
||||
decisions D-034..D-072 + W1..BA + Q1.3. The accuracy risk is
|
||||
mitigated by P1 Wave 1 (verify STATE.md against sources before
|
||||
archive). No revision needed — the verification step is in the plan.
|
||||
|
||||
### G-Q4 — Does the NFR purity gate (zero `feat:` commits) hold for v1.27?
|
||||
|
||||
**Challenge:** v1.27 authors STATE.md (a new file). Is authoring a new
|
||||
catalog file a `feat:` (feature) that breaks the NFR purity gate?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.92). D-220 (CLARIFY) resolved this:
|
||||
STATE.md is documentation (a catalog of *existing* capabilities), not a
|
||||
new platform capability. The archive moves are `chore:` (file
|
||||
relocation, lossless). No code, no schema, no platform behavior
|
||||
change. The NFR purity gate (zero `feat:` commits) holds. All v1.27
|
||||
commits use `docs(P0N):` or `chore(P01):` prefixes. No revision
|
||||
needed.
|
||||
|
||||
### G-Q5 — Does fixing PROJECT.md phase-status in P2 create a P0/P1 audit inconsistency?
|
||||
|
||||
**Challenge:** The PROJECT.md phase-status block shows P3/P4/P5 as
|
||||
"pending" (the bug flagged in the prior conversation). P0 + P1 ship
|
||||
with the bug still present (the fix is P2). Does the P0/P1 audit see
|
||||
the inconsistency?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.86). The bug is pre-existing
|
||||
(it predates v1.27; it was the trigger for the prior conversation).
|
||||
P0/P1 audits check the *v1.27* commits against the `.ciagent/` state,
|
||||
not the pre-existing PROJECT.md drift. The P2 fix is the correction;
|
||||
the P3 audit verifies the fix landed. The intermediate state (P0/P1
|
||||
with the bug present) is honest — the bug is documented in the v1.27
|
||||
PLAN + the prior conversation, and the fix is scheduled. No revision
|
||||
needed — the phasing is intentional (D-225: P1 additive, P2
|
||||
correction).
|
||||
|
||||
### G-Q6 — Is the milestone scoped too small (3 phases, 8 archive moves)?
|
||||
|
||||
**Challenge:** v1.27 is a small milestone (3 phases, ~15 file
|
||||
operations, no code). Is it worth a milestone, or should it be a
|
||||
patch on v1.26?
|
||||
|
||||
**Verdict:** PROCEED (confidence 0.88). v1.27 is not a patch on v1.26
|
||||
— v1.26 is shipped (`v1.25.5`, merged to main, milestone complete).
|
||||
The work is a new milestone by definition. The size is appropriate:
|
||||
STATE.md is a durable PO-facing artifact (loaded every ci-run going
|
||||
forward); the compression reduces active context by ~26%; the
|
||||
ship-discipline wiring affects every future milestone ship. Small but
|
||||
high-leverage. No revision needed.
|
||||
**3 critical conditions (must-fix before P1) + 16 tracked conditions.**
|
||||
No escalations (all axes ≥ 0.70 confidence).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
## Axis verdicts
|
||||
|
||||
6 challenges; 0 escalations; 1 binding revision (G-Q2, already
|
||||
captured in PLAN Task 2.1). Overall verdict: PROCEED (confidence
|
||||
0.88).
|
||||
| Axis | Verdict | Confidence | Critical condition |
|
||||
|------|---------|-----------|-------------------|
|
||||
| §1 Feasibility | PROCEED-WITH-CONDITIONS | 0.82 | C-1.1 KMS asym verify; C-1.2 Argon2 fail-closed test |
|
||||
| §2 Scope | PROCEED-WITH-CONDITIONS | 0.74 | C-2.1 fold P5 into P4; C-2.2 P4 overload |
|
||||
| §3 Cost | PROCEED-WITH-CONDITIONS | 0.70 | C-3.1 cost estimate; C-3.2 CodeArtifact P1 task |
|
||||
| §4 Schedule | PROCEED-WITH-CONDITIONS | 0.76 | C-4.1 P4 critical path; C-4.2 per-phase exit |
|
||||
| §5 Technical Depth | PROCEED-WITH-CONDITIONS | 0.80 | C-5.1 ABAC shape; **C-5.2 JWS KDF** |
|
||||
| §6 Operational Readiness | PROCEED-WITH-CONDITIONS | 0.72 | **C-6.1 ABAC fail-closed**; C-6.2 threat model; C-6.3 ops guide |
|
||||
| §7 Security Posture | PROCEED-WITH-CONDITIONS | 0.73 | **C-7.1 ABAC fail-closed**; C-7.2 Argon2 params; C-7.3 cred file |
|
||||
| §8 Dependency Risk | PROCEED-WITH-CONDITIONS | 0.83 | C-8.1 CodeArtifact P1; C-8.2 pin kj version |
|
||||
| §9 Re-mapping Integrity | PROCEED-WITH-CONDITIONS | 0.84 | **C-9.1 traceability fix**; C-9.2 INV audit |
|
||||
|
||||
**Binding revisions:**
|
||||
- **G-Q2:** Archive list refined to 7 platform + 1 consumer = 8 files.
|
||||
The 4 pre-execution files (CLARIFY/GRILL/IDEATE/RESEARCH) stay active
|
||||
through v1.27 (they hold the v1.27 P0 content); the v1.26-era content
|
||||
is in git history. Already in PLAN.
|
||||
---
|
||||
|
||||
**No work is blocked.** The milestone is feasible, scoped, the
|
||||
compression is lossless (archive + git history), the STATE.md backfill
|
||||
is source-grounded with a verification step, the NFR purity holds, and
|
||||
the phasing (P1 additive, P2 correction, P3 ship) is sound.
|
||||
## Critical conditions (the 3 must-fix-before-P1)
|
||||
|
||||
### 🔴 C-6.1 / C-7.1 — ABAC fail-closed
|
||||
The token-vend Lambda's behavior on `kj` absence/error is unspecified.
|
||||
Without fail-closed, INV-17 is documentation, not a runtime guarantee —
|
||||
a `kj` load failure would bypass the ABAC gate (every PAT gets a token).
|
||||
**Fix applied to PLAN.md P4 Wave 4 Task 4.1:** "If
|
||||
`KyvernoJsonEngine.is_configured()` returns false or `evaluate()`
|
||||
raises, return 403 + audit `token.vend.denied` (reason:
|
||||
`abac_eval_failed`). Never fail open. Test: `tests/test_abac_fail_closed.py`."
|
||||
|
||||
### 🔴 C-5.2 — JWS-from-PAT key derivation
|
||||
REQ-332's AC ("public key derivable from the PAT") is unimplementable
|
||||
without a specified KDF. A PAT is a JWT, not a keypair.
|
||||
**Fix applied to PLAN.md P2 Wave 2 Task 2.3 + REQ-332 AC:** the JWS
|
||||
uses HMAC-SHA256 with a key derived via
|
||||
`HKDF-SHA256(PAT_bytes, salt='nova-local-attestation', info='jws-signing-key')`
|
||||
→ 32-byte symmetric key. The "public key derivable" AC is re-interpreted:
|
||||
the *verification key* is derived from the PAT via the same KDF (the
|
||||
PAT is the shared secret). This is a symmetric scheme, not asymmetric.
|
||||
|
||||
### 🔴 C-9.1 — Traceability drift
|
||||
REQUIREMENTS.md §v1.28 traceability table mapped 16 REQs to P2;
|
||||
PLAN.md splits them across P2/P3/P4/P5/P6. **Fix applied to
|
||||
REQUIREMENTS.md** — traceability table updated to match PLAN.md phase
|
||||
structure.
|
||||
|
||||
---
|
||||
|
||||
## Tracked conditions (16 — applied to PLAN.md as amendments)
|
||||
|
||||
- **C-1.1** KMS asymmetric key verification before P4 Wave 3 (one
|
||||
`aws kms create-key --key-spec ECC_NIST_P256` call).
|
||||
- **C-1.2** Argon2 fail-closed test in P3 Wave 2 (Lambda returns 503
|
||||
on `ImportError`, not a crash or pure-Python hash).
|
||||
- **C-2.1** Fold P5 (idp-setup) into P4 as P4 Wave 8 → **reduces to 6
|
||||
execution phases** (P1..P6, P7 = final). Applied.
|
||||
- **C-2.2** P4 is a double-length phase; acknowledged in P4 header.
|
||||
- **C-3.1** Cost envelope subsection added to PLAN.md.
|
||||
- **C-3.2 / C-8.1** CodeArtifact provisioning = P1 Wave 0 task with
|
||||
binary go/no-go gate; Gitea wheel index fallback documented.
|
||||
- **C-4.1** P4 flagged as critical-path phase (kj spike = highest-
|
||||
probability schedule slip; Fargate = +1 week).
|
||||
- **C-4.2** Per-phase exit criteria added to PLAN.md.
|
||||
- **C-5.1** `requested_claims` = list of claim names (the policy
|
||||
asserts the subject is *allowed* to request those claims).
|
||||
- **C-6.2** Threat model (REQ-347) adds: JWKS DDoS surface, PAT theft
|
||||
+ max TTL (≤24h dev, ≤1h service-account), ABAC fail-closed,
|
||||
INV-18..21 compression audit.
|
||||
- **C-6.3** Operator guide (REQ-345) adds: KMS rotation, layer update,
|
||||
PITR restore, emergency PAT revocation.
|
||||
- **C-7.2** Argon2id parameters: t=3, m=65536 KiB, p=1 (OWASP min).
|
||||
- **C-7.3** `~/.nova/credentials.json` stores OIDC token + PAT metadata
|
||||
(jti, exp, type), NOT the raw PAT.
|
||||
- **C-8.2** `kj` pinned to a specific release + SHA256 recorded.
|
||||
- **C-9.2** Threat model includes INV-18..21 compression audit
|
||||
(verify spec's attestation invariant semantics are captured by
|
||||
INV-15/16/17 + REQ-332).
|
||||
|
||||
---
|
||||
|
||||
## Escalations
|
||||
|
||||
None. All 9 axes resolved at confidence ≥ 0.70. No human escalation
|
||||
required (full autonomy).
|
||||
|
||||
---
|
||||
|
||||
## Grill complete
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
+248
-58
@@ -1,77 +1,267 @@
|
||||
---
|
||||
project: acdl
|
||||
milestone: v1.27
|
||||
milestone: v1.28
|
||||
generated_at: 2026-08-19
|
||||
generator: lead-developer
|
||||
verification_toolchain:
|
||||
typecheck: "python3 -m py_compile core/confidence_signal.py 2>&1 | head -5 || true"
|
||||
test: "bash scripts/run_regression.sh 2>&1 | tail -10 || true"
|
||||
lint: "ruff check .ciagent/STATE.md 2>/dev/null || true"
|
||||
typecheck: "python3 -m py_compile core/mode_resolver.py nova/cli.py 2>&1 | head -5 || true"
|
||||
test: "pytest tests/test_mode_resolver.py tests/test_cli_subcommands.py -q 2>&1 | tail -15 || true"
|
||||
lint: "ruff check nova/ core/lambda/nova_idp_*.py 2>/dev/null || true"
|
||||
note: |
|
||||
v1.27 is an NFR milestone (PO State Catalog & Ciagent Compression) —
|
||||
a docs/chore milestone. Single active persona: lead-developer owns
|
||||
the milestone narrative (STATE.md authoring, PROJECT/ROADMAP fixes,
|
||||
archive moves, PLAN/NORTH_STAR wiring, final review + audit). No
|
||||
code, no schema, no policy authoring. The pre-existing
|
||||
core/confidence_signal.py LSP diagnostic is out of scope (not
|
||||
touched by v1.27). Territory enforcement: warn.
|
||||
v1.28 is a feature milestone (CLI Canonicalization + Identity Layer).
|
||||
Four active personas: backend-engineer (Lambda/DynamoDB/KMS/CodeArtifact),
|
||||
security-engineer (Argon2id/KMS/ABAC/threat model), cli-engineer
|
||||
(subcommand surface/mode_resolver/argparse/CAP-034), lead-developer
|
||||
(plan/review/ship/capability gate). frontend-engineer + data-engineer
|
||||
deactivated (no UI, no data pipelines). The kj-binary-in-Lambda-layer
|
||||
risk (D-227, RESEARCH §7) is the highest-risk item; P2 spike confirms.
|
||||
---
|
||||
|
||||
# PERSONAS — v1.27 PO State Catalog & Ciagent Compression
|
||||
# Personas — v1.28 CLI Canonicalization + Identity Layer
|
||||
|
||||
> Generated by the lead-developer at the end of RESEARCH. Assesses the
|
||||
> project domains, activates/deactivates personas, aligns frameworks +
|
||||
> territory + constraints to the actual project structure.
|
||||
## Roster
|
||||
|
||||
## Active Roster (1)
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "Lambda functions, DynamoDB, KMS integration, dual-use packaging, CodeArtifact publish, CloudFormation generation"
|
||||
frameworks: ["Python 3.12", "boto3", "argparse", "pytest", "moto[dynamodb]", "CloudFormation"]
|
||||
constraints: ["INV-15", "INV-16", "INV-17", "D-228", "D-229", "D-230", "NFR-5", "NFR-6", "NFR-7", "NFR-8"]
|
||||
territory:
|
||||
- "core/lambda/**"
|
||||
- "core/metrics/**"
|
||||
- "core/env.py"
|
||||
- "core/outbox_writer.py"
|
||||
- "terraform/bootstrap/**"
|
||||
- ".gitea/workflows/publish.yml"
|
||||
- ".github/workflows/publish.yml"
|
||||
- ".github/actions/nova-cli/**"
|
||||
```
|
||||
|
||||
### 1. lead-developer (active)
|
||||
- **active:** true
|
||||
- **phase_specific:** false
|
||||
- **reason:** Owns the full v1.27 milestone narrative: STATE.md
|
||||
authoring (PO-facing capability catalog, 36 entries across 10
|
||||
domains + 11 invariants), archive moves (11 files to
|
||||
`.ciagent/archive/` + 1 to consumer archive), PROJECT.md + ROADMAP.md
|
||||
phase-status corrections, archive/README.md contents update,
|
||||
PLAN.md + ROADMAP.md + NORTH_STAR.md ship-discipline wiring, final
|
||||
review + audit.
|
||||
- **domain:** `.ciagent/` docs (STATE.md, PROJECT.md, ROADMAP.md,
|
||||
PLAN.md, NORTH_STAR.md, archive/README.md), consumer
|
||||
`.ciagent/nova-blockchain-exchange/` (PROJECT.md pointer,
|
||||
archive/ROADMAP-v1.26.md).
|
||||
- **frameworks:** markdown, JSON (CHECKPOINT.json, config.json).
|
||||
- **territory:** `.ciagent/`, `docs/`.
|
||||
- **constraints:** no code changes (NFR milestone, D-220); no schema
|
||||
changes; archive moves are lossless (byte-identical relocation, git
|
||||
history preserves authoritative state); STATE.md is additive only.
|
||||
### security-engineer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "Argon2id hashing, KMS asymmetric signing (ECDSA P-256 / ES256), ABAC policy, JWKS exposure, PAT lifecycle, threat model, DER→raw ECDSA conversion"
|
||||
frameworks: ["argon2-cffi", "cryptography", "pyjwt", "kyverno-json", "JMESPath", "KMS Sign/Verify/GetPublicKey"]
|
||||
constraints: ["INV-15", "INV-16", "INV-17", "NFR-5", "NFR-8", "NFR-9", "D-227", "D-231"]
|
||||
territory:
|
||||
- "platform/abac/**"
|
||||
- "core/policy_engine.py"
|
||||
- "adapters/kyverno-json/**"
|
||||
- "core/lambda/nova_idp_auth.py"
|
||||
- "core/lambda/nova_idp_token_vend.py"
|
||||
- "core/lambda/nova_idp_jwks.py"
|
||||
- "docs/threat-model.md"
|
||||
```
|
||||
|
||||
## Deactivated (5)
|
||||
### cli-engineer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "CLI subcommand surface, mode_resolver, argparse, [project.scripts] entry-point, CAP-034 AST scan, nova auth/idp subgroups, property tests"
|
||||
frameworks: ["Python 3.12", "argparse", "setuptools [project.scripts]", "hypothesis", "pkgutil"]
|
||||
constraints: ["INV-12", "INV-13", "INV-14", "D-226", "NFR-1", "NFR-2", "NFR-3"]
|
||||
territory:
|
||||
- "nova/**"
|
||||
- "core/mode_resolver.py"
|
||||
- "pyproject.toml"
|
||||
- "tests/test_mode_resolver.py"
|
||||
- "tests/test_cli_subcommands.py"
|
||||
```
|
||||
|
||||
### backend-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No code changes in v1.27. The pre-existing
|
||||
`core/confidence_signal.py` LSP diagnostic is out of scope (not
|
||||
touched by v1.27).
|
||||
### lead-developer
|
||||
```yaml
|
||||
active: true
|
||||
domain: "Phase plan, persona roster, review gates, milestone ship, capability gate (CAP-033..038), ROADMAP/STATE/PROJECT wiring"
|
||||
frameworks: ["git", "Gitea Actions", "semver tagging", ".ciagent/ discipline"]
|
||||
constraints: ["INV-1..17 (cross-cutting)", "v1.28 hard constraints", "NFR-6", "NFR-11"]
|
||||
territory:
|
||||
- ".ciagent/**"
|
||||
- "PLAN.md"
|
||||
- "CHECKPOINT.json"
|
||||
- "STATE.md"
|
||||
- "REQUIREMENTS.md"
|
||||
- "ROADMAP.md"
|
||||
```
|
||||
|
||||
### data-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No schema, migration, or ORM changes.
|
||||
### frontend-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: "No UI in v1.28 (CLI + JSON endpoints only). JWKS serves application/json; no HTML/CSS/JS surface."
|
||||
```
|
||||
|
||||
### policy-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No policy authoring. STATE.md Domain 3 catalogues
|
||||
existing v1.25 + v1.26 policies (descriptive, not authoring).
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: "No data pipelines / metrics / PowerBI work in v1.28. The metrics layer is v1.17-complete; v1.28 adds audit events but no new fact/dim tables."
|
||||
```
|
||||
|
||||
### frontend-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No UI. Deactivated since v1.26 (PERSONAS.md:141).
|
||||
## Territory overlap notes
|
||||
|
||||
### blockchain-engineer (inactive)
|
||||
- **active:** false
|
||||
- **reason:** No chain code. The v1.26 pilot is shipped; v1.27 is
|
||||
platform-side docs/chore only.
|
||||
- `core/lambda/contract_ingestor.py` (dual-use refactor, REQ-329) =
|
||||
backend-engineer territory. `core/lambda/nova_idp_auth.py` +
|
||||
`nova_idp_token_vend.py` are **co-owned** by backend-engineer (Lambda
|
||||
plumbing, DynamoDB, function URLs) + security-engineer (crypto, ABAC,
|
||||
Argon2id logic inside).
|
||||
- `core/mode_resolver.py` = cli-engineer. `core/policy_engine.py` =
|
||||
security-engineer (the ABAC evaluation path).
|
||||
- `nova/idp/setup.py` = cli-engineer (the subcommand + arg parsing) +
|
||||
backend-engineer (the CloudFormation generation + deploy).
|
||||
- `nova/auth/*` = cli-engineer (subcommands) + security-engineer (the
|
||||
token exchange + credential storage logic).
|
||||
|
||||
## Territory Enforcement
|
||||
## Phase-specific personas
|
||||
|
||||
- **Mode:** `warn` (the milestone is `.ciagent/`-only; the lead-
|
||||
developer owns all writes; no cross-territory collisions expected).
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
# 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).
|
||||
+1007
-216
File diff suppressed because it is too large
Load Diff
+227
-2
@@ -344,7 +344,7 @@ plan-JSON policies + pipeline wiring (REQ-300,301,302), meta-policies
|
||||
(REQ-303), regression-gate policies (REQ-304,305), docs + adapter README
|
||||
(REQ-306,307), tests (REQ-308,309).
|
||||
|
||||
## v1.26 — Live Pilot Estate Activation (active)
|
||||
## v1.26 — Live Pilot Estate Activation (complete, tag `v1.25.5`, merged to main 2026-08-19)
|
||||
|
||||
> **Active milestone.** Feature milestone — the first real consumer estate
|
||||
> (a stock exchange on a homegrown PoA blockchain, equities only) is
|
||||
@@ -438,4 +438,229 @@ already exist).
|
||||
> live in `.ciagent/PLAN.md` (the active phase plan, retained in full).
|
||||
> v1.26 pre-execution artifacts (CLARIFY/GRILL/IDEATE/RESEARCH) are in
|
||||
> git history (pre-v1.27-P0 commits); the v1.26 phase verifications +
|
||||
> review are archived at `.ciagent/archive/{VERIFY-P03,VERIFY-P04,REVIEW-AUDIT-P05}.md`.
|
||||
> review are archived at `.ciagent/archive/{VERIFY-P03,VERIFY-P04,REVIEW-AUDIT-P05}.md`.
|
||||
|
||||
## v1.27 — PO State Catalog & Ciagent Compression (complete, tag `v1.26.3`, merged to main 2026-08-19)
|
||||
|
||||
> **NFR milestone — complete.** STATE.md authored (32 CAPs, 11 invariants,
|
||||
> 10 domains). 8 outdated `.ciagent/` files archived (7 platform + 1
|
||||
> consumer). PROJECT.md + ROADMAP.md v1.26 phase-status corrected.
|
||||
> STATE.md wired into P-final ship discipline. Tags: `v1.26.0` (P0) →
|
||||
> `v1.26.1..v1.26.2` (P1..P2) → `v1.26.3` (P3 final = milestone release).
|
||||
> 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 (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
|
||||
> `nova <subcommand>`, the CLI and Lambda functions share a single
|
||||
> `core/` source tree, and Nova owns its identity layer end-to-end
|
||||
> (sign-up through token vending) with no AWS-managed identity services
|
||||
> in the path. Nova-idp is introduced: two Lambda functions (`nova-idp-auth`,
|
||||
> `nova-idp-token-vend`), KMS-signed OIDC tokens, ABAC-gated token vending
|
||||
> via the existing kyverno-json engine (INV-4 swappable), and PAT
|
||||
> lifecycle (issuance, revocation, status).
|
||||
>
|
||||
> Tags run on the **v1.27.x** line: `v1.27.0` (P0) → `v1.27.1..v1.27.N`
|
||||
> (execution phases) → `v1.27.(N+1)` (final phase = milestone release).
|
||||
> Milestone branch: `milestone/v1.28-cli-identity`.
|
||||
|
||||
### v1.28 ID allocations (re-mapped — no collisions with shipped history)
|
||||
|
||||
- **Decisions:** `D-226..D-231` (authored in CLARIFY). Repo decision
|
||||
namespace is `D-NNN` (max D-225); no `D-NEW-*` namespace exists.
|
||||
- **Requirements:** `REQ-323..REQ-353` (31 REQs, mapping the spec's
|
||||
REQ-001..REQ-031 1:1). Max existing REQ = REQ-322.
|
||||
- **Capabilities:** `CAP-033..CAP-038` (mapping the spec's CAP-025..CAP-030).
|
||||
Existing CAP-025..032 are blockchain/pilot — collision avoided.
|
||||
- **Invariants:** `INV-12..INV-17` (mapping the spec's INV-63,64,65,18..21,34).
|
||||
Max existing INV = INV-11.
|
||||
- **`kj` engine → kyverno-json.** The spec references a `kj` engine; the
|
||||
repo's actual policy engine is `kyverno-json` (INV-4 swappable). v1.28
|
||||
uses kyverno-json as the ABAC evaluator for token-vend; no new `kj`
|
||||
engine is built. This is a CLARIFY-grounded re-mapping, not a silent
|
||||
assumption (D-229).
|
||||
|
||||
### v1.28 Requirements
|
||||
|
||||
New requirements REQ-323..REQ-353 — full text in
|
||||
`.ciagent/REQUIREMENTS.md` §v1.28. Summary by priority:
|
||||
|
||||
- **P1 — CLI Substrate (REQ-323..REQ-328):** CodeArtifact wheel + Lambda
|
||||
layer pipeline; CLI subcommand per `core/` module; `nova init`
|
||||
scaffolding; `nova cli-action` published to GitHub + Gitea;
|
||||
`mode_resolver.py` (flag → env → credential type → TTY); audit
|
||||
emission with `mode` + `selection_reason`.
|
||||
- **P2 — Lambda Packaging + Identity Layer (REQ-329..REQ-344):** dual-use
|
||||
`core/lambda/contract_ingestor.py`; local env synthesizer; JWS signing
|
||||
key from PAT; `nova-idp-auth` Lambda (Argon2id, DynamoDB); DynamoDB
|
||||
tables (`nova-users`, `nova-sessions`, `nova-password-resets`);
|
||||
`nova-idp-token-vend` Lambda (KMS-signed OIDC, JWKS endpoint); kyverno-json
|
||||
ABAC policy at `platform/abac/token-vend.policy`; `nova idp setup`
|
||||
(`--check/--apply/--verify`); CloudFormation review; PAT issuance +
|
||||
hashes in DynamoDB; `nova auth login/revoke/status`.
|
||||
- **P3 — Documentation (REQ-345..REQ-347):** operator guide for
|
||||
`nova idp setup`; developer guide for `nova auth login`; identity-layer
|
||||
threat model.
|
||||
- **P4 — Integration Testing (REQ-348..REQ-351):** E2E sign-up → sign-in →
|
||||
token-vend → apply → audit; property tests for `mode_resolver`; KMS
|
||||
round-trip test; PAT revocation SLO test (≤60s P95).
|
||||
- **P5 — Capability Gate (REQ-352..REQ-353):** CAP-033..038 verification
|
||||
gates wired into CI.
|
||||
|
||||
### v1.28 Hard constraints
|
||||
|
||||
- DO NOT depend on Cognito, IAM Identity Center, or any AWS-managed
|
||||
identity service for sign-up/sign-in/token-vending (NFR-5). Nova-idp
|
||||
signs OIDC tokens directly via KMS. (Note: no Cognito exists in the
|
||||
repo today — this is a greenfield build, not a "Cognito drop".)
|
||||
- DO NOT build a new `kj` engine — use kyverno-json (INV-4).
|
||||
- DO NOT enforce MFA/TOTP for prod/dr this milestone — ship the code path,
|
||||
enforce in v1.21+ (deferred, INV scope).
|
||||
- DO NOT add WebAuthn/FIDO2, upstream IdP federation, or password breach
|
||||
detection — deferred to v1.23+.
|
||||
- DO NOT add Lambda layer auto-update on `core/` changes — v1.18 ships
|
||||
manual `nova layer update`; v1.19 adds CI-triggered auto-update.
|
||||
- The token-vend Lambda MUST evaluate the kyverno-json ABAC policy before
|
||||
signing; allow/deny decisions MUST be emitted to the audit stream
|
||||
(NFR-9, D-227).
|
||||
- `nova idp setup --apply` MUST present the CloudFormation template for
|
||||
review before any resource is created (NFR-10).
|
||||
|
||||
### 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.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.
|
||||
|
||||
> 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`.
|
||||
+549
-1
@@ -299,4 +299,552 @@
|
||||
|
||||
Full v1.26 requirement text:
|
||||
`.ciagent/nova-blockchain-exchange/REQUIREMENTS.md`. Active phase plan:
|
||||
`.ciagent/PLAN.md`.
|
||||
`.ciagent/PLAN.md`.
|
||||
|
||||
## 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
|
||||
> `nova <subcommand>`; the CLI and Lambda functions share a single
|
||||
> `core/` source tree; and Nova owns its identity layer end-to-end
|
||||
> (Nova-idp: `nova-idp-auth` + `nova-idp-token-vend` Lambdas, KMS-signed
|
||||
> OIDC tokens, kyverno-json ABAC token vending, PAT lifecycle). No
|
||||
> AWS-managed identity services in the path.
|
||||
>
|
||||
> Tags run on the **v1.27.x** line: `v1.27.0` (P0) →
|
||||
> `v1.27.1..v1.27.N` → `v1.27.(N+1)` (final = milestone release).
|
||||
> Milestone branch: `milestone/v1.28-cli-identity`.
|
||||
>
|
||||
> **ID re-mapping (no collisions):** the source spec used `REQ-001..031`,
|
||||
> `CAP-025..030`, `INV-63/64/65/18..21/34`, `D-NEW-26/37..41`, and a `kj`
|
||||
> engine — none of which exist in this repo (CAP-025..032 and
|
||||
> INV-1..11 are already allocated to blockchain/pilot work; the policy
|
||||
> engine is kyverno-json, not `kj`). This file uses the re-mapped IDs:
|
||||
> `REQ-323..353`, `CAP-033..038`, `INV-12..17`, `D-226..231`. The 1:1
|
||||
> mapping is recorded in CLARIFY.md. Decisions D-226..D-231 are authored
|
||||
> in CLARIFY (full autonomy) — they are not pre-existing "locked inputs".
|
||||
|
||||
### Decisions (locked in CLARIFY — full autonomy, load-bearing for v1.28)
|
||||
|
||||
- **D-226 (Mode resolution priority):** flag → env (`NOVA_CLIENT_MODE`) →
|
||||
credential type → TTY heuristic. Invalid env values are ignored + warned,
|
||||
falling through to credential type. No silent fallbacks (NFR-1).
|
||||
- **D-227 (ABAC engine = kyverno-json):** the token-vend Lambda uses the
|
||||
existing kyverno-json engine (INV-4 swappable) as the ABAC evaluator,
|
||||
not a new `kj` engine. Policy at `platform/abac/token-vend.policy`.
|
||||
- **D-228 (Argon2id in Lambda):** `argon2-cffi` with bundled wheels; if
|
||||
the C extension fails to load, fall back to the pure-Python
|
||||
implementation; if both fail, document the Fargate migration path.
|
||||
- **D-229 (PAT revocation SLO):** strongly-consistent DynamoDB read on
|
||||
every token-vend request; revocation takes effect within 60s P95 (NFR-4).
|
||||
- **D-230 (JWKS endpoint):** Lambda function URL behind a custom domain;
|
||||
rate limiting at the DNS/CDN layer. API Gateway migration deferred to
|
||||
v1.19+ if throttling requirements grow.
|
||||
- **D-231 (ABAC policy ownership + versioning):** Platform Security owns
|
||||
`platform/abac/token-vend.policy`; changes require PR review; the
|
||||
policy version (git SHA) is recorded in every token-vend audit event.
|
||||
|
||||
### P1 — CLI Substrate
|
||||
|
||||
#### REQ-323 — CodeArtifact wheel + Lambda layer pipeline
|
||||
**Journeys:** J3. **Priority:** High.
|
||||
**AC:** Given a merge to `main` affecting `core/`, when CI runs, then both
|
||||
the wheel and the Lambda layer are published to CodeArtifact with
|
||||
identical version strings; if either fails, the merge is rejected.
|
||||
|
||||
#### REQ-324 — CLI subcommand per `core/` module
|
||||
**Journeys:** J3. **Priority:** High.
|
||||
**AC:** (1) Every module in `core/` has a corresponding `nova/<module>.py`
|
||||
subcommand. (2) Subcommand files are ≤ 50 lines and contain no business
|
||||
logic — they delegate to `core/`. (3) CAP-034 verifies delegation by AST
|
||||
scan.
|
||||
|
||||
#### REQ-325 — `nova init` scaffolds project
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given a directory with no `.nova/`, when Dev runs `nova init`,
|
||||
then `.nova/`, `.nova/contract.yml.attestations/`, and `.gitignore`
|
||||
(excluding secrets) are created.
|
||||
|
||||
#### REQ-326 — `nova cli-action` published
|
||||
**Journeys:** J3. **Priority:** High.
|
||||
**AC:** (1) Action is available on both GitHub and Gitea marketplaces.
|
||||
(2) Integration test verifies byte-identical behavior on both platforms.
|
||||
(3) Python 3.12 is pinned.
|
||||
|
||||
#### REQ-327 — `mode_resolver.py` priority
|
||||
**Journeys:** J2, J3. **Priority:** High.
|
||||
**AC:** (1) Explicit `--mode=agent|interactive` flag always wins.
|
||||
(2) Otherwise `NOVA_CLIENT_MODE` env var. (3) Otherwise credential type
|
||||
default. (4) Otherwise TTY heuristic. (5) Property tests cover all four
|
||||
levels. (6) INV-13 (mode determinism) enforced at PR time.
|
||||
|
||||
#### REQ-328 — Audit emission with mode + selection_reason
|
||||
**Journeys:** J3. **Priority:** High.
|
||||
**AC:** Given any CLI invocation, when the CLI runs, then the emitted
|
||||
`cli.invocation` audit event contains `mode`, `selection_reason`,
|
||||
`credential_type`, `command`, and `args`. INV-12 (mode observability)
|
||||
enforced.
|
||||
|
||||
### P2 — Lambda Packaging + Identity Layer
|
||||
|
||||
#### REQ-329 — Dual-use Lambda/CLI import
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given `core/lambda/contract_ingestor.py`, when imported from the
|
||||
Lambda handler, then it executes the Lambda path; when imported from the
|
||||
CLI, then it executes the local path; and the two paths share ≥ 80% of
|
||||
their code.
|
||||
|
||||
#### REQ-330 — Local env synthesizer
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given a contract and a `--local` flag, when `nova apply --local`
|
||||
runs, then a local env is synthesized via `core/env.py:get_env()` without
|
||||
provisioning cloud resources.
|
||||
|
||||
#### REQ-331 — Attestations directory scaffolded
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given `nova init` ran, when Dev lists
|
||||
`.nova/contract.yml.attestations/`, then the directory exists and is empty.
|
||||
|
||||
#### REQ-332 — JWS signing key from PAT
|
||||
**Journeys:** J2. **Priority:** High.
|
||||
**AC:** Given a PAT, when Dev runs `nova apply --local --sign-local-review`,
|
||||
then a JWS attestation is produced; the JWS is HMAC-SHA256 with a key
|
||||
derived from the PAT via `HKDF-SHA256(PAT_bytes, salt='nova-local-attestation',
|
||||
info='jws-signing-key')` → 32-byte symmetric key (C-5.2 grill fix). The
|
||||
verification key is derived from the PAT via the same KDF (the PAT is
|
||||
the shared secret). INV-14..17 (attestation invariants) enforced.
|
||||
|
||||
#### REQ-333 — `nova-idp-auth` Lambda
|
||||
**Journeys:** J1, J2. **Priority:** High.
|
||||
**AC:** (1) Lambda exposes sign-up, sign-in, and session creation
|
||||
endpoints. (2) Passwords are hashed with Argon2id. (3) Sessions are
|
||||
stored in DynamoDB. (4) CAP-036 verifies end-to-end auth flow.
|
||||
|
||||
#### REQ-334 — Argon2id password hashing
|
||||
**Journeys:** J1, J2. **Priority:** High.
|
||||
**AC:** Given a sign-up request, when the user record is persisted, then
|
||||
the password is stored as an Argon2id hash; raw passwords never appear in
|
||||
logs, traces, environment variables, or DynamoDB records.
|
||||
|
||||
#### REQ-335 — DynamoDB tables for identity
|
||||
**Journeys:** J1. **Priority:** High.
|
||||
**AC:** (1) Tables exist: `nova-users`, `nova-sessions`,
|
||||
`nova-password-resets`. (2) Tables are provisioned by `nova idp setup`.
|
||||
(3) Point-in-time recovery is enabled on each.
|
||||
|
||||
#### REQ-336 — `nova-idp-token-vend` Lambda
|
||||
**Journeys:** J1, J2, J4. **Priority:** High.
|
||||
**AC:** (1) Lambda accepts a PAT (or session token) and returns a
|
||||
KMS-signed OIDC token. (2) Token claims include `sub`, `aud`, `iss`,
|
||||
`exp`, and role claims. (3) ABAC policy is evaluated before signing.
|
||||
|
||||
#### REQ-337 — KMS-signed OIDC tokens
|
||||
**Journeys:** J1, J4. **Priority:** High.
|
||||
**AC:** (1) Signing key is a KMS asymmetric key (RSA or ECDSA).
|
||||
(2) Token signature is verifiable via the JWKS endpoint. (3) KMS
|
||||
round-trip test passes. CAP-037 verifies.
|
||||
|
||||
#### REQ-338 — JWKS endpoint as Lambda function URL
|
||||
**Journeys:** J1, J4. **Priority:** High.
|
||||
**AC:** Given the identity stack is deployed, when a client GETs the JWKS
|
||||
URL, then the public key(s) for token verification are returned with
|
||||
`Content-Type: application/json`.
|
||||
|
||||
#### REQ-339 — kyverno-json ABAC policy file
|
||||
**Journeys:** J1, J4. **Priority:** High.
|
||||
**AC:** (1) Policy at `platform/abac/token-vend.policy`. (2) Policy inputs
|
||||
include subject, requested claims, target resource, and environment.
|
||||
(3) kyverno-json `evaluate` returns allow/deny; the decision is emitted to
|
||||
the audit stream.
|
||||
|
||||
#### REQ-340 — `nova idp setup` walks admin
|
||||
**Journeys:** J1. **Priority:** High.
|
||||
**AC:** (1) Command supports `--check`, `--apply`, and `--verify` modes.
|
||||
(2) `--check` reports missing prerequisites and the required IAM policy.
|
||||
(3) `--apply` generates a CloudFormation template and requires explicit
|
||||
approval. (4) `--verify` runs the KMS round-trip test.
|
||||
|
||||
#### REQ-341 — CloudFormation template for review
|
||||
**Journeys:** J1. **Priority:** High.
|
||||
**AC:** Given `nova idp setup --apply`, when the template is generated,
|
||||
then the template is presented for review; resources are not created until
|
||||
the operator approves; `--dry-run` shows the resource list without writing.
|
||||
|
||||
#### REQ-342 — PAT issuance via portal
|
||||
**Journeys:** J4. **Priority:** High.
|
||||
**AC:** (1) PAT is a signed JWT. (2) PAT hash is stored in DynamoDB.
|
||||
(3) PAT includes a unique `jti` and an expiry claim. (4) Revocation marks
|
||||
the `jti` as revoked.
|
||||
|
||||
#### REQ-343 — PAT hashes in DynamoDB
|
||||
**Journeys:** J4. **Priority:** High.
|
||||
**AC:** (1) Only the hash (not the raw PAT) is stored. (2) Table supports
|
||||
lookup-by-hash and lookup-by-`jti`. (3) Revoked PATs are retained for
|
||||
audit, not deleted.
|
||||
|
||||
#### REQ-344 — `nova auth` commands
|
||||
**Journeys:** J2, J4. **Priority:** High.
|
||||
**AC:** (1) `nova auth login` exchanges session → OIDC token, stores
|
||||
locally. (2) `nova auth revoke --pat <id>` marks a PAT revoked.
|
||||
(3) `nova auth status` shows current credential, mode, and
|
||||
selection_reason. (4) All commands emit audit events.
|
||||
|
||||
### P3 — Documentation
|
||||
|
||||
#### REQ-345 — Operator guide for `nova idp setup`
|
||||
**Priority:** High.
|
||||
**AC:** Guide published covering `--check`, `--apply`, `--verify`,
|
||||
prerequisite IAM policy, and the CloudFormation review flow.
|
||||
|
||||
#### REQ-346 — Developer guide for `nova auth login`
|
||||
**Priority:** High.
|
||||
**AC:** Guide published covering signup, signin, login, mode resolution,
|
||||
and credential-type behavior at a TTY vs. piped stdout.
|
||||
|
||||
#### REQ-347 — Identity-layer threat model
|
||||
**Priority:** High.
|
||||
**AC:** Threat model published covering Argon2id storage, KMS signing,
|
||||
JWKS exposure, PAT revocation SLO, ABAC token vending, and the no-AWS-
|
||||
managed-identity constraint (NFR-5).
|
||||
|
||||
### P4 — Integration Testing
|
||||
|
||||
#### REQ-348 — E2E integration test
|
||||
**Priority:** High.
|
||||
**AC:** Given a deployed Nova-idp, when the test runs, then sign-up →
|
||||
sign-in → token-vend → apply → audit completes successfully; the audit
|
||||
event chain is verifiable.
|
||||
|
||||
#### REQ-349 — Property tests for `mode_resolver`
|
||||
**Priority:** High.
|
||||
**AC:** (1) Property tests cover all four priority levels. (2) Edge cases:
|
||||
TTY but piped stdout, missing credential, conflicting flag/env, invalid
|
||||
env value. (3) INV-13 enforced via test.
|
||||
|
||||
#### REQ-350 — KMS round-trip test
|
||||
**Priority:** High.
|
||||
**AC:** Given a token signed by the token-vend Lambda, when the test
|
||||
fetches the JWKS and verifies the signature, then verification succeeds.
|
||||
|
||||
#### REQ-351 — PAT revocation SLO test
|
||||
**Priority:** High.
|
||||
**AC:** Issue PAT → use to vend token → revoke → assert denial within 60s
|
||||
P95. Test passes in CI.
|
||||
|
||||
### P5 — Capability Gate
|
||||
|
||||
#### REQ-352 — CAP-033..038 gate rules wired into CI
|
||||
**Priority:** High.
|
||||
**AC:** (1) CAP-033 (CLI subcommand surface exists): `nova --help` lists a
|
||||
subcommand for every `core/` module. (2) CAP-034 (subcommand delegates to
|
||||
`core/`): every `nova/<module>.py` ≤ 50 lines, no business logic, AST
|
||||
scan. (3) CAP-035 (layer matches wheel): Lambda layer ARN version matches
|
||||
the `nova-cli` wheel version. (4) CAP-036 (Nova-idp auth flow works): E2E
|
||||
test (REQ-348) passes. (5) CAP-037 (token-vend signs via KMS): KMS
|
||||
round-trip (REQ-350) passes. (6) CAP-038 (PAT issuance + revocation):
|
||||
REQ-351 passes. Failure of any → merge blocked.
|
||||
|
||||
#### REQ-353 — Capability gate GREEN for v1.28 release
|
||||
**Priority:** High.
|
||||
**AC:** CAP-001..CAP-032 remain Verified; CAP-033..CAP-038 are Verified.
|
||||
All v1.28 release-gate criteria in PLAN.md §6 met.
|
||||
|
||||
### v1.28 Invariants (new — INV-12..INV-17)
|
||||
|
||||
- **INV-12 (Mode observability):** Every CLI invocation emits a
|
||||
`cli.invocation` audit event containing `mode`, `selection_reason`,
|
||||
`credential_type`, `command`, and `args`.
|
||||
- **INV-13 (Mode resolution determinism):** Resolution priority is
|
||||
flag → env (`NOVA_CLIENT_MODE`) → credential type → TTY. No silent
|
||||
fallbacks. Deviations rejected at PR time.
|
||||
- **INV-14 (Credential type encodes role):** `developer_pat` /
|
||||
`nova_oidc_token` + TTY present → `interactive`; TTY absent → `agent`.
|
||||
- **INV-15 (No AWS-managed identity in path):** Nova-idp MUST NOT depend
|
||||
on Cognito, IAM Identity Center, or any AWS-managed identity service.
|
||||
- **INV-16 (Password storage):** Passwords hashed with Argon2id; raw
|
||||
passwords never in logs/traces/env/DynamoDB.
|
||||
- **INV-17 (ABAC discipline):** The token-vend Lambda evaluates the
|
||||
kyverno-json ABAC policy before signing; allow/deny + policy inputs
|
||||
emitted to the audit stream.
|
||||
|
||||
### v1.28 Traceability (live — see CHECKPOINT.json for authoritative state)
|
||||
|
||||
| REQ | Phase | Status |
|
||||
|-----|-------|--------|
|
||||
| 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 (M1 gate: nova-platform-ops) |
|
||||
| REQ-356 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-357 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-358 | covered-reference | planned (M2 gate: nova-platform-ops) |
|
||||
| REQ-359 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-360 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-361 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-362 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-363 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-363b | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-364 | covered-reference | planned (M1.5 gate: nova-platform-ops) |
|
||||
| REQ-365 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-366 | covered-reference | planned (M1 gate: nova-platform-ops) |
|
||||
| REQ-371 | covered-reference | planned (M2 gate: nova-platform-ops) |
|
||||
+564
-118
@@ -1,144 +1,590 @@
|
||||
# Nova — v1.27 Research Findings
|
||||
# Nova — v1.28 Research Findings
|
||||
|
||||
> Phase: research (pre-execution). Milestone: v1.27 (PO State Catalog &
|
||||
> Ciagent Compression). Status: research. Researcher: ci-researcher.
|
||||
> Phase: research (pre-execution). Milestone: v1.28 (CLI Canonicalization
|
||||
> + Identity Layer). Status: research. Researcher: ci-researcher.
|
||||
> Autonomy: full.
|
||||
|
||||
v1.27 is an NFR milestone (docs/chore only, no code, no schema). There
|
||||
is no new domain to research. The research is a codebase-grounded
|
||||
inventory of (a) the files to archive + their staleness evidence, and
|
||||
(b) the sources backing the STATE.md capability backfill. This file
|
||||
records the inventory for the v1.27 record; the active authoring used
|
||||
these sources directly.
|
||||
>
|
||||
> Research delegated to the ci-researcher subagent (full domain/ecosystem
|
||||
> research with web citations). This file is the curated summary; the
|
||||
> full 868-line research document is preserved in git history (the
|
||||
> subagent's task output). Key findings + recommendations are below.
|
||||
|
||||
---
|
||||
|
||||
## 1. Files to archive (staleness inventory)
|
||||
## §1 — Codebase Inventory (grounding)
|
||||
|
||||
### 1.1 Pre-execution artifacts (v1.26 — shipped, decisions folded)
|
||||
### 1.1 `core/` modules (the REQ-324 subcommand surface)
|
||||
|
||||
| File | Lines | Staleness evidence | Decisions folded into |
|
||||
|---|---|---|---|
|
||||
| `CLARIFY.md` | 225 | v1.26 milestone shipped (`v1.25.5`); decisions D-200..D-213 | `PROJECT.md` load-bearing decisions |
|
||||
| `GRILL.md` | 225 | v1.26 grill verdict PROCEED 0.84; binding revisions applied | `PLAN.md` revisions (G-Q4 REQ-322→P2 W0; G-Q6 enforcement deferred; G-Q9 key-split future) |
|
||||
| `IDEATE.md` | 193 | all 7 accepted ideas → REQ-315..322 (shipped) | `REQUIREMENTS.md` v1.26 traceability |
|
||||
| `RESEARCH.md` | 250 | v1.26 domain research (blockchain, deploy, modules, metrics) | `ARCHITECTURE.md` §12.8; shipped REQs |
|
||||
19 Python files under `core/` (plus `core/lambda/`, `core/metrics/`).
|
||||
Two already have `_cli.py` companions (`contract_resolver_cli.py` 40
|
||||
lines, `regression_verify_cli.py` 32 lines) — the thin-delegate
|
||||
precedent for `nova/<module>.py`. **No `nova/` dir, no `bin/`, no
|
||||
`[project.scripts]` entry exists today.** The CLI is greenfield.
|
||||
|
||||
All four are pre-execution artifacts for a shipped milestone. The next
|
||||
P0 writes fresh versions. Per D-219 (user-confirmed) + D-222: archive
|
||||
all four with `-v1.26` suffixes.
|
||||
### 1.2 Existing Lambda pattern (`core/lambda/contract_ingestor.py`)
|
||||
|
||||
### 1.2 Phase verifications + review (v1.26 — shipped, PASS)
|
||||
521 lines. Function URL + IAM auth (D-051). DynamoDB via lazy
|
||||
module-global `boto3.resource`. Secrets Manager for tokens. Schema
|
||||
validation in-Lambda. **`__main__` block already does CLI dispatch**
|
||||
(`--check-readiness` → `core.submission_readiness.cli_main`) — this is
|
||||
the dual-use precedent for REQ-329. Local testing via
|
||||
`core/local_emulators.py:LocalLambdaStub`.
|
||||
|
||||
| File | Lines | Staleness evidence |
|
||||
### 1.3 `core/env.py` — getter, not synthesizer
|
||||
|
||||
31 lines. `get_env(name, default)` reads `NOVA_<name>` from `os.environ`.
|
||||
**REQ-330 needs a NEW `synthesize_local_env()` function** added here.
|
||||
The closest existing pattern is `core/onboarding.py:generate_env_file()`.
|
||||
|
||||
### 1.4 `PolicyEngine` Protocol + `KyvernoJsonEngine` (the ABAC substrate)
|
||||
|
||||
`core/policy_engine.py`: `PolicyEngine` Protocol with `evaluate(payload,
|
||||
policy_dir, contract_id) -> list[dict]`. `KyvernoJsonEngine` shells to
|
||||
`kj scan --policy <dir> --payload <file> --output json`. Policy shape =
|
||||
`ValidatingPolicy` (`apiVersion: json.kyverno.io/v1alpha1`) with
|
||||
`spec.rules[].assert.all[].check` using JMESPath. Severity from
|
||||
`metadata.annotations["nova.cloudinit.dev/severity"]`. **The payload
|
||||
can be ANY JSON** — not just contracts (the v1.25 design point). This
|
||||
is what makes kyverno-json usable for ABAC token vending (D-227).
|
||||
|
||||
### 1.5 `pyproject.toml` state
|
||||
|
||||
name `nova`, version `1.14.0`, requires-python `>=3.10` (spec wants
|
||||
3.12 — bump needed for REQ-326). setuptools build backend. No
|
||||
`[project.scripts]`, no `[tool.setuptools.packages.find]` — both needed.
|
||||
Deps: `boto3`, `jsonschema`, `pyyaml`. No `argon2-cffi`, `cryptography`,
|
||||
`pyjwt`, `click`/`typer` — **argparse-only** is the repo convention.
|
||||
|
||||
### 1.6 Forge conventions
|
||||
|
||||
`.github/workflows/` + `.gitea/workflows/` kept byte-identical. Python
|
||||
3.12 already pinned via `actions/setup-python@v5`. No composite action
|
||||
exists yet — `nova cli-action` (REQ-326) is greenfield.
|
||||
|
||||
### 1.7 IAM baseline (load-bearing for REQ-340)
|
||||
|
||||
`.ciagent/IAM_POLICY.md` + `terraform/bootstrap/spike_runner_policy.json`.
|
||||
The `nova-spike-runner` principal already has KMS (incl. `CreateKey`,
|
||||
`Sign`, `GetPublicKey`), Lambda (incl. `PublishLayerVersion`), DynamoDB
|
||||
grants. **New grants needed:** `cloudformation:*` (for `nova idp setup
|
||||
--apply`) + `codeartifact:*` (for the wheel publish pipeline). Flagged
|
||||
for P1/P2.
|
||||
|
||||
---
|
||||
|
||||
## §2 — CodeArtifact + Lambda Layer Pipeline (REQ-323)
|
||||
|
||||
**Recommendation:** single CI job on merge to `main` affecting
|
||||
`core/**`/`adapters/**`/`nova/**`/`pyproject.toml`. Build wheel
|
||||
(`python -m build --wheel`) → `twine upload` to CodeArtifact → build
|
||||
layer (`pip install --target layer/python/ dist/nova-*.whl argon2-cffi
|
||||
cryptography pyjwt`) → `aws lambda publish-layer-version` → record
|
||||
version mapping in SSM `/nova/layer/nova-cli/version` (CAP-035). If
|
||||
either publish fails, the job fails (merge blocked, REQ-323 AC).
|
||||
|
||||
**Atomicity:** wheel publish is idempotent (pin version to
|
||||
`<semver>+<sha7>`); layer publish retries on failure. CAP-035 reads the
|
||||
SSM parameter to verify layer-version ↔ wheel-version match.
|
||||
|
||||
**Risks:** CodeArtifact not yet provisioned in `581513795199` (CLARIFY
|
||||
assumption #1); `codeartifact:*` grant missing. Fallback: Gitea-hosted
|
||||
wheel index. Layer `--compatible-architectures`: build x86_64 only for
|
||||
v1.28 (aarch64 only if Graviton Lambda needed).
|
||||
|
||||
---
|
||||
|
||||
## §3 — CLI Subcommand Architecture (REQ-324)
|
||||
|
||||
**Recommendation:** three-layer. `nova/__init__.py` (marker) →
|
||||
`nova/cli.py` (~80 lines, auto-discovers `nova/<module>.py` via
|
||||
`pkgutil.iter_modules`, dispatches, emits `cli.invocation` audit event)
|
||||
→ `nova/<module>.py` (≤50 lines each, exports `add_parser(subparsers)`
|
||||
+ `run(args) -> int`, delegates to `core/`). Entry point:
|
||||
`[project.scripts] nova = "nova.cli:main"`. **argparse-only** (no
|
||||
click/typer — repo convention).
|
||||
|
||||
**CAP-034 AST scan:** ≤50 lines; ≤3 function defs; every `ast.Call`
|
||||
resolves to a `core.` import; no conditionals beyond `if __name__`.
|
||||
|
||||
**Subcommand groups:** `nova auth`, `nova idp`, `nova metrics` =
|
||||
nested subparsers (same pattern, one level deeper).
|
||||
|
||||
**setuptools:** add `[tool.setuptools.packages.find]` including `nova`,
|
||||
`nova.*`, `core`, `core.*`, `adapters.*`.
|
||||
|
||||
---
|
||||
|
||||
## §4 — Argon2id in Lambda Python 3.12 (REQ-334, D-228)
|
||||
|
||||
**Findings:** `argon2-cffi-bindings` v25.1.0 ships `cp39-abi3`
|
||||
manylinux x86_64 + aarch64 wheels — **ABI-stable, compatible with
|
||||
Python 3.9..3.13**. Lambda Python 3.12 runs Amazon Linux 2023 (glibc
|
||||
2.34 ≥ 2.28 required). **The abi3 manylinux wheel loads cleanly.**
|
||||
Confidence: 0.92.
|
||||
|
||||
**D-228 AMENDMENT:** the "pure-Python fallback" clause is **weaker than
|
||||
stated** — there is no maintained pure-Python Argon2 implementation. A
|
||||
pure-Python crypto fallback is a **liability** (weaker hashing,
|
||||
violates INV-16's spirit). Revised recommendation:
|
||||
1. **Primary:** bundled manylinux abi3 wheel in the `nova-cli` Lambda
|
||||
layer. Works. Confidence 0.92.
|
||||
2. **Fallback:** detect `ImportError` at Lambda cold-start → **fail
|
||||
closed** (503, refuse sign-ups). The Lambda health check reports
|
||||
C-extension status. **Do NOT ship a pure-Python fallback.**
|
||||
3. **Escape hatch:** Fargate (~1 week, per CLARIFY Q1).
|
||||
|
||||
Lambda memory ≥ 512 MB (Argon2id memory_cost ~20 MB + overhead).
|
||||
|
||||
---
|
||||
|
||||
## §5 — KMS Asymmetric Signing for OIDC Tokens (REQ-337)
|
||||
|
||||
**Recommendation: key spec = `ECC_NIST_P256`, alg = `ECDSA_SHA_256`
|
||||
(JWS `ES256`).** RSA-2048 is larger + slower; P-256 is RFC 7518's
|
||||
recommended JWT alg. Signature size 64 bytes (vs RSA 256). JWKS
|
||||
compactness matters (fetched often).
|
||||
|
||||
**The #1 gotcha:** KMS returns DER-encoded ECDSA signatures; **JWS
|
||||
requires raw r‖s concatenation** (RFC 7515 §3.1.3). The token-vend
|
||||
Lambda converts via `cryptography.hazmat.primitives.asymmetric.utils.
|
||||
decode_dss_signature` → `r.to_bytes(32) + s.to_bytes(32)`. ~5 lines.
|
||||
Flagged for the threat model (REQ-347) + KMS round-trip test (REQ-350).
|
||||
|
||||
**Flow:** validate PAT → ABAC eval → build JWT header/payload →
|
||||
`kms.sign(Message=signing_input, MessageType="RAW", SigningAlgorithm=
|
||||
"ECDSA_SHA_256")` → DER→raw → JWT. `kid` = KMS key alias.
|
||||
|
||||
**Verification:** use `pyjwt` (`jwt.decode` handles JWK→key natively);
|
||||
`cryptography` only for SPKI→JWK in the JWKS Lambda.
|
||||
|
||||
**Rotation:** manual, 90 days (matches D-069 CMK cadence). New key +
|
||||
re-point alias + JWKS serves both `kid`s during overlap.
|
||||
|
||||
---
|
||||
|
||||
## §6 — JWKS Endpoint (REQ-338, D-230)
|
||||
|
||||
**D-230 confirmed.** Lambda function URL (`AuthType: NONE` — JWKS is
|
||||
public-key only) + reserved concurrency 10 (max 100 RPS, JWKS is
|
||||
cached client-side). `Cache-Control: max-age=3600`. Separate tiny
|
||||
`nova-idp-jwks` Lambda (separation of concerns).
|
||||
|
||||
**Custom domain + WAF = OPTIONAL** via `--public-jwks-domain <domain>`
|
||||
flag on `nova idp setup`. Without it, raw function URL (acceptable for
|
||||
v1.28 pilot). With it: CloudFront + ACM + WAF rate-based rule (>100
|
||||
req/5min per IP) + Route53 ALIAS. Adds ~8 CloudFormation resources.
|
||||
|
||||
**Defer API Gateway** (D-230) — $3.50/M + complexity for no benefit at
|
||||
v1.28 volume.
|
||||
|
||||
---
|
||||
|
||||
## §7 — kyverno-json ABAC Policy (REQ-339, D-227)
|
||||
|
||||
**D-227 confirmed.** Policy at `platform/abac/token-vend.policy` =
|
||||
`ValidatingPolicy` with JMESPath checks against a payload of
|
||||
`{subject, requested_claims, target_resource, environment, pat_jti,
|
||||
policy_version}`. Decision logic: any `fail` PCR with severity
|
||||
`critical` → deny (403 + audit); all pass → allow → KMS sign.
|
||||
|
||||
**`policy_version` (D-231):** git SHA of the policy file, baked into
|
||||
the Lambda layer, recorded in every `token.vend.allowed/denied` audit
|
||||
event.
|
||||
|
||||
**BIGGEST PACKAGING RISK:** the token-vend Lambda needs the `kj` Go
|
||||
binary (~40 MB) on PATH. Bundle it in the `nova-cli` Lambda layer
|
||||
(`wget` the Linux amd64 release into `layer/bin/kj`). `KyvernoJsonEngine
|
||||
.is_configured()` checks `which kj` → `/opt/bin/kj` (layer mount). P2
|
||||
spike confirms it runs in AL2023 Lambda. Fallback: Fargate. Confidence
|
||||
0.75 — needs the spike.
|
||||
|
||||
---
|
||||
|
||||
## §8 — PAT Lifecycle (REQ-342, REQ-343, REQ-344)
|
||||
|
||||
**PAT = signed JWT** (KMS-signed, `typ: "developer_pat"` distinguishes
|
||||
from `nova_oidc_token` per INV-14). Claims: `iss, sub, typ, jti, iat,
|
||||
exp, roles, owner`.
|
||||
|
||||
**`nova-pats` DynamoDB table** (4th table): PK=`jti`, GSI1=`sub` (list
|
||||
PATs for user), GSI2=`pat_hash` (lookup by hash). Only the hash stored
|
||||
(not raw PAT). Revoked PATs retained for audit.
|
||||
|
||||
**Revocation (D-229 CLARIFIED):** GSIs don't support strongly-consistent
|
||||
reads. The token-vend Lambda extracts `jti` from the PAT JWT (decode
|
||||
without verifying — signature verified separately) →
|
||||
`GetItem(PK=jti, ConsistentRead=True)` on the main table. Satisfies the
|
||||
60s SLO. Confidence 0.90.
|
||||
|
||||
**CLI:** `nova auth login` (session→OIDC token, store locally),
|
||||
`nova auth revoke --pat <jti>`, `nova auth status` (active credential,
|
||||
mode, selection_reason). Local file `~/.nova/credentials.json` (0600,
|
||||
never to stdout, in `.gitignore`). "Most recent wins" (D-226 Q5) =
|
||||
`active_credential_jti` field.
|
||||
|
||||
---
|
||||
|
||||
## §9 — `nova idp setup` CloudFormation (REQ-340, REQ-341)
|
||||
|
||||
**Template (raw dict → JSON, no troposphere dep):** 2-3 Lambdas, 4
|
||||
DynamoDB tables (`nova-users`, `nova-sessions`, `nova-password-resets`,
|
||||
`nova-pats`), KMS key `alias/nova-oidc-signing` (ECC_NIST_P256),
|
||||
function URLs, IAM roles, optional CloudFront/WAF/ACM.
|
||||
|
||||
**`--check`:** validates prerequisites (AWS creds, CFN perms, KMS perms,
|
||||
layer exists via CAP-035). Prints required IAM policy delta.
|
||||
**`--apply`:** generate → print to temp file + resource summary →
|
||||
`$PAGER` → `Apply? [y/N]` → `cloudformation deploy --capabilities
|
||||
CAPABILITY_IAM`. NFR-10 satisfied by the explicit prompt.
|
||||
**`--dry-run`:** resource list only, no write.
|
||||
**`--verify`:** runs the KMS round-trip test (REQ-350).
|
||||
|
||||
**New IAM grants needed:** `cloudformation:*`, `iam:CreateRole`/`PassRole`,
|
||||
`lambda:CreateFunction`/`CreateFunctionUrlConfig`,
|
||||
`dynamodb:CreateTable`, `kms:CreateKey`/`CreateAlias`, `ssm:PutParameter`.
|
||||
|
||||
---
|
||||
|
||||
## §10 — GitHub + Gitea Marketplace Composite Action (REQ-326)
|
||||
|
||||
**Single `action.yml`** at `.github/actions/nova-cli/action.yml`,
|
||||
referenced by both GitHub + Gitea via `uses: continuous-intelligence/
|
||||
acdl/.github/actions/nova-cli@v1.28`. Composite action: `setup-python@v5`
|
||||
(python 3.12) → CodeArtifact login + `pip install nova` → `nova
|
||||
${{ inputs.command }}`. `NOVA_CLIENT_MODE` env from input.
|
||||
|
||||
**Byte-identical test (REQ-326 AC2):** CI matrix runs the action on
|
||||
GitHub `ubuntu-latest` + Gitea `act_runner` with same inputs; assert
|
||||
same stdout/exit code.
|
||||
|
||||
**Risk:** Gitea `actions/checkout`/`setup-python` may need Gitea
|
||||
mirrors (`https://gitea.com/actions/...`). P1 test on the actual Gitea
|
||||
instance. Confidence 0.70.
|
||||
|
||||
---
|
||||
|
||||
## §11 — `mode_resolver` Priority (REQ-327, D-226)
|
||||
|
||||
**TTY detection: check `sys.stdin.isatty()`** (NOT stdout). Edge 3
|
||||
(`nova apply | tee log.txt`): stdout piped, stdin is TTY → user is
|
||||
present → `interactive` (correct). `sys.stdout.isatty()` would
|
||||
misresolve to `agent`. **`stdin` answers "is a human at a terminal?"**
|
||||
|
||||
**Credential type detection:** read `~/.nova/credentials.json` →
|
||||
`active_credential_jti`'s `type` (`developer_pat`/`nova_oidc_token`).
|
||||
Both + TTY → `interactive`; + no TTY → `agent` (INV-14).
|
||||
|
||||
**Property tests (REQ-349):** `hypothesis` with strategies for
|
||||
flag/env/cred/tty. Properties: deterministic (INV-13), flag-wins,
|
||||
invalid-env-ignored, no-silent-fallback (every resolution has a
|
||||
non-empty `selection_reason`).
|
||||
|
||||
**`mode_resolver.py` lives in `core/`** (not `nova/`) so Lambdas could
|
||||
import it, but **it's CLI-only** — the token-vend Lambda doesn't resolve
|
||||
modes.
|
||||
|
||||
---
|
||||
|
||||
## §12 — Persona Assessment
|
||||
|
||||
See `.ciagent/PERSONAS.md` for the full YAML roster. Summary:
|
||||
- **Deactivate** frontend-engineer (no UI) + data-engineer (no data
|
||||
pipelines in v1.28).
|
||||
- **Activate** backend-engineer (Lambda/DynamoDB/KMS/CodeArtifact) +
|
||||
lead-developer (plan/review/ship).
|
||||
- **Add** security-engineer (Argon2id/KMS/ABAC/threat model) +
|
||||
cli-engineer (subcommand surface/mode_resolver/argparse/CAP-034).
|
||||
|
||||
---
|
||||
|
||||
## §13 — Architecture Sketch (ARCHITECTURE.md §12.10)
|
||||
|
||||
See `.ciagent/ARCHITECTURE.md` §12.10 (appended this stage). New
|
||||
greenfield files: `nova/` CLI package, `platform/abac/token-vend.policy`,
|
||||
`core/mode_resolver.py`, `core/env.py:+synthesize_local_env()`,
|
||||
`core/lambda/nova_idp_{auth,token_vend,jwks}.py`, `tests/test_*`,
|
||||
`docs/{operator-guide-idp,developer-guide-auth,threat-model}.md`.
|
||||
|
||||
---
|
||||
|
||||
## Decisions re-validated / amended
|
||||
|
||||
| Decision | Status | Change |
|
||||
|---|---|---|
|
||||
| `VERIFY-P03.md` | 39 | v1.26 P3 verification — PASS; shipped `v1.25.3` |
|
||||
| `VERIFY-P04.md` | 31 | v1.26 P4 verification — PASS; shipped `v1.25.4` |
|
||||
| `REVIEW-AUDIT-P05.md` | 218 | v1.26 P5 final review + audit — PROCEED; shipped `v1.25.5`; 0 P0 remain; audit CLEAN |
|
||||
| `P4-PILOT-RUN-EVIDENCE.md` | 46 | v1.26 live apply evidence (`blkex-pilot-apply-v0.2`); summarized in `nova-blockchain-exchange/README.md` §5 + REVIEW-AUDIT-P05 §2.2 |
|
||||
| D-226 | re-validated + refined | `sys.stdin.isatty()` is the TTY check (not stdout) |
|
||||
| D-227 | re-validated | `kj` Go binary bundled in Lambda layer — packaging risk flagged |
|
||||
| D-228 | **amended** | Pure-Python fallback → fail-closed + Fargate (pure-Python crypto is a liability) |
|
||||
| D-229 | re-validated + clarified | Strong read on main table PK (`jti`), not GSI (GSIs don't support strong reads) |
|
||||
| D-230 | re-validated | CloudFront/WAF/ACM made optional via `--public-jwks-domain` flag |
|
||||
| D-231 | re-validated | `policy_version` (git SHA) in the ABAC payload |
|
||||
|
||||
### 1.3 Durable references (superseded or stale)
|
||||
**New recommendations for PLAN/GRILL to formalize (no D-ID yet):**
|
||||
- KMS key spec = `ECC_NIST_P256`, alg `ES256`; DER→raw ECDSA conversion required.
|
||||
- `nova-cli` Lambda layer bundles the `kj` Go binary (~40 MB).
|
||||
- `nova-pats` = 4th DynamoDB table; PK=`jti`, GSI1=`sub`, GSI2=`pat_hash`.
|
||||
- `sys.stdin.isatty()` is the TTY heuristic.
|
||||
- `[project.scripts] nova = "nova.cli:main"`; argparse-only.
|
||||
- `cloudformation:*` + `codeartifact:*` = new IAM baseline grants (P1/P2).
|
||||
|
||||
| File | Lines | Staleness evidence | Superseded by |
|
||||
|---|---|---|---|
|
||||
| `CAPABILITY_INVENTORY.md` | 120 | dated 2026-07-27; framed as "v1.1→v1.8 re-verification sweep"; predates v1.26 pilot (CAP-025 absent; blockchain capabilities absent) | `STATE.md` (this milestone) |
|
||||
| `AUTONOMY_THESIS.md` | 65 | "Last refined: v1.21"; thesis fully folded into `NORTH_STAR.md` Vision (lines 17–22) + Anti-Goals #2 | `NORTH_STAR.md` |
|
||||
| `COST.md` | 106 | dated 2026-07-29; framed "v1.0 → v1.14"; predates v1.26 live pilot (ECS + ALB + DynamoDB + S3 costs not reflected) | A future cost milestone writes a fresh report; `STATE.md` Domain 7 notes cost tracking as a capability |
|
||||
---
|
||||
|
||||
### 1.4 Consumer-side (nova-blockchain-exchange)
|
||||
## RESEARCH complete
|
||||
|
||||
| File | Lines | Staleness evidence |
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
# 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 |
|
||||
|---|---|---|
|
||||
| `nova-blockchain-exchange/ROADMAP.md` | 57 | marks P3/P4/P5 as "planned" but v1.26 shipped (`v1.25.5`); phase narrative preserved in platform `ROADMAP.md` v1.26 section |
|
||||
| `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. |
|
||||
|
||||
Per D-221: consumer archives land in
|
||||
`.ciagent/nova-blockchain-exchange/archive/ROADMAP-v1.26.md`.
|
||||
## §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.
|
||||
|
||||
---
|
||||
|
||||
## 2. Files to keep active (no-edit or fix-only)
|
||||
## New decisions for the decision ledger (research-derived)
|
||||
|
||||
### 2.1 No-edit (live code paths or durable)
|
||||
|
||||
| File | Why keep active |
|
||||
|---|---|
|
||||
| `CHECKPOINT.json` | Authoritative resume state — never archive |
|
||||
| `config.json` | Operational config — never archive |
|
||||
| `REGRESSION_REPORT.json` | Written by `core/regression_verify.py:705`; read by `core/metrics/collector.py:27` + `trust_snapshot.py:21` + metrics views (D-224: regenerates on next `run_regression.sh`) |
|
||||
| `REGRESSION_REPORT.md` | Written by `core/regression_verify.py:704`; read by `scripts/run_regression.sh` (D-224) |
|
||||
| `PERSONAS.md` | Regenerated at each milestone P0 by the lead-developer; not stale until then |
|
||||
| `IAM_POLICY.md` | Live baseline, test-enforced (`tests/test_iam_policy_baseline.py`); D-207 future key-split pending (D-223) |
|
||||
| `PLAN.md` | Active phase plan; reset to next milestone at next P0 |
|
||||
| `ARCHITECTURE.md` | Durable target architecture (§1–§12 + §12.7 + §12.8 + §12.9) |
|
||||
| `NORTH_STAR.md` | PO strategy; loaded every ci-run via `config.strategic_direction_file` |
|
||||
| `nova-blockchain-exchange/PROJECT.md` | Consumer project charter; D-200..D-205 load-bearing |
|
||||
| `nova-blockchain-exchange/REQUIREMENTS.md` | REQ-310..322 spec intent (shipped but spec stays for reference) |
|
||||
| `nova-blockchain-exchange/README.md` | Consumer onboarding guide; still accurate (deploy workflow, secrets, contract shape, verification) |
|
||||
|
||||
### 2.2 Fix-only (corrections to stale-but-kept files)
|
||||
|
||||
| File | Fix |
|
||||
|---|---|
|
||||
| `PROJECT.md` | v1.26 phase-status block (lines 424–431): P3/P4/P5 "pending" → "complete" with shipped tags `v1.25.3/4/5`; add STATE.md pointer (D-225: P2 phase) |
|
||||
| `ROADMAP.md` | v1.26 P3/P4/P5 sections (lines 238, 261, 272) "planned" → "complete" with shipped tags; v1.26 Overview line "active" → "complete"; add STATE.md to P5 ship-update list (D-225: P2 phase) |
|
||||
| `archive/README.md` | Add the 11 new archived files to the contents tables (P2 phase) |
|
||||
|
||||
---
|
||||
|
||||
## 3. STATE.md capability backfill sources
|
||||
|
||||
The STATE.md backfill (36 capabilities across 10 domains) was sourced
|
||||
from:
|
||||
|
||||
| Source | Used for |
|
||||
|---|---|
|
||||
| `core/regression_verify.py` (lines 129–768) | CAP-001..CAP-025 IDs, names, tiers, evidence pointers |
|
||||
| `.ciagent/CAPABILITY_INVENTORY.md` (pre-archive) | CAP-001..022 descriptions, defect notes, evidence |
|
||||
| `modules/registry.json` | L1/L2 module catalog (13 L1 + 2 L2 entries) |
|
||||
| `.ciagent/REQUIREMENTS.md` v1.25 traceability | REQ-291..309 → policy-engine capabilities |
|
||||
| `.ciagent/nova-blockchain-exchange/REQUIREMENTS.md` + `.ciagent/REQUIREMENTS.md` v1.26 traceability | REQ-310..322 → pilot capabilities |
|
||||
| `.ciagent/CHECKPOINT.json` | shipped tags `v1.25.0..v1.25.5` |
|
||||
| `git log --all --oneline` | file paths for v1.26 shipped features |
|
||||
| `.ciagent/PROJECT.md` load-bearing decisions | INV-1..INV-11 invariants |
|
||||
| `docs/submission-readiness.md` + `schemas/contract.schema.json` | INV-1 contract surface |
|
||||
|
||||
---
|
||||
|
||||
## 4. Persona assessment
|
||||
|
||||
v1.27 is a docs/chore milestone. The active roster:
|
||||
|
||||
- **lead-developer** (active): owns the milestone narrative (STATE.md
|
||||
authoring, PROJECT/ROADMAP fixes, archive README, PLAN/NORTH_STAR
|
||||
wiring, this RESEARCH, CLARIFY, PLAN, final review + audit). Territory:
|
||||
`.ciagent/`, `docs/`.
|
||||
- **backend-engineer** (active, limited): no code changes in v1.27.
|
||||
Consulted on the `core/confidence_signal.py` LSP diagnostic (pre-
|
||||
existing, not touched by v1.27). No territory writes.
|
||||
- **data-engineer** (inactive): no schema/migration/ORM changes.
|
||||
- **policy-engineer** (inactive): no policy authoring.
|
||||
- **frontend-engineer** (inactive): no UI.
|
||||
- **blockchain-engineer** (inactive): no chain code.
|
||||
|
||||
Territory enforcement: warn. The milestone is `.ciagent/`-only; the
|
||||
lead-developer owns all writes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk analysis
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
| D-ID | Title | Confidence | Source |
|
||||
|---|---|---|---|
|
||||
| Archive move breaks a relative path reference in an active file | Low | Medium | `grep` for the archived filenames across `.ciagent/` + `docs/` before commit; fix any dangling references in P2 |
|
||||
| STATE.md capability row is inaccurate (wrong shipped tag / wrong file path) | Medium | Low | The backfill sources are the authoritative registries (regression_verify.py, registry.json, CHECKPOINT.json, git log); citations are direct |
|
||||
| PROJECT.md phase-status fix conflicts with a future v1.26-era commit | Low | Low | v1.26 is shipped (main has the milestone merge); no v1.26-era commits will arrive |
|
||||
| REGRESSION_REPORT stale state is mistaken for v1.27 scope | Low | Low | D-224 records the decision; STATE.md Domain 7 notes the current CAP range |
|
||||
| **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.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verdict
|
||||
## RESEARCH complete
|
||||
|
||||
v1.27 is feasible, scoped, and the sources are grounded. No new domain,
|
||||
no new code, no schema breaks. The archive moves are lossless (git
|
||||
history + archive directory both preserve bytes). The STATE.md
|
||||
backfill is sourced from authoritative registries. Proceed to PLAN.
|
||||
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`.
|
||||
|
||||
|
||||
+271
-5
@@ -11,10 +11,30 @@
|
||||
> *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).
|
||||
> **v1.29 (pending — tag `v1.28.6`):** Reposplit + Identity Layer
|
||||
> Bring-Live. Platform operations extracted to a Gitea-private
|
||||
> Terraform repo (`nova-platform-ops`, OPER-PRIV); `acdl/acdl`
|
||||
> standardized on GitHub (D-232); Nova-idp brought live in
|
||||
> `581513795199` via Terraform (CFN archived, REQ-369); `kj` substrate
|
||||
> has one ECR image digest shared by the Lambda runtime + its Fargate
|
||||
> fallback (KJ-LOCKSTEP, REQ-371, D-238); JWKS edge-only via CloudFront
|
||||
> + OAC (INV-18, D-233). 3 new capabilities (CAP-039..041), 1 new
|
||||
> invariant (INV-18), 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-240). Covered-reference REQs (355-366, 371) verified via
|
||||
> M1/M1.5/M2 cutover gates in `docs/operator-guide-platform-ops.md`.
|
||||
> **Next update:** at v1.30 ship.
|
||||
|
||||
## How to use this file (PO)
|
||||
|
||||
@@ -80,6 +100,53 @@
|
||||
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.
|
||||
- **INV-18 (JWKS-EDGE-ONLY, v1.29):** the JWKS endpoint is the only
|
||||
public read surface of the live platform. All other platform
|
||||
endpoints MUST gate with `AuthType: AWS_IAM` (D-233). CloudFront +
|
||||
OAC pinning replaces direct Lambda Function URL exposure. Direct
|
||||
Function URL → 403; via-CloudFront → 200.
|
||||
|
||||
> **v1.29 NFR constraints (10 — load-bearing, not full invariants):**
|
||||
> KJ-STATIC (`kj` compiled `CGO_ENABLED=0`, `file(1)` reports
|
||||
> `statically linked`, SHA-256 in Terraform state); KJ-LOCKSTEP
|
||||
> (Fargate standby digest == Lambda `image_uri` digest at every
|
||||
> `terraform plan`, enforced by `lifecycle.precondition` + CI + PR
|
||||
> comment + operator review, D-238); KJ-WARMUP-HEALTH (Fargate
|
||||
> `GET /health → 200` every 10s, READY before M1 cutover);
|
||||
> OPER-PRIV (`nova-platform-ops` `private: true`, not mirrored,
|
||||
> REQ-359); IAM-NARROW (Gitea OIDC role bounded, no `Action: "*"` or
|
||||
> `Resource: "*"`, REQ-360); DRIFT-DETECT (`terraform plan` exit 2
|
||||
> fails the apply workflow, REQ-356); IMPORT-IDEMPOTENT (re-import
|
||||
> exits `resource_already_imported`, REQ-361); TFM-HITL (`terraform
|
||||
> apply` against `main` requires Gitea Actions approval from a user
|
||||
> distinct from the PR author, REQ-357, INV-3); JWKS-SLO
|
||||
> (`GET /.well-known/jwks.json` P95 < 200ms same-region,
|
||||
> `Cache-Control: max-age=3600`); JWKS-ROTATION (on key rotation,
|
||||
> both old + new public keys published during 24-hour overlap
|
||||
> window).
|
||||
|
||||
## Domains (capability groups)
|
||||
|
||||
@@ -93,6 +160,8 @@
|
||||
8. Consumer surfaces (developer + agentic)
|
||||
9. Pilot estate (v1.26)
|
||||
10. Forge / CI runtime
|
||||
11. CLI + Identity Layer (v1.28)
|
||||
12. Platform Ops Reposplit (v1.29)
|
||||
|
||||
## Capabilities (additive — one row per shipped capability)
|
||||
|
||||
@@ -248,6 +317,46 @@
|
||||
| — | 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 |
|
||||
|
||||
### Domain 12 — Platform Ops Reposplit (v1.29)
|
||||
|
||||
> **Pending — tag v1.28.6 (milestone release).** Rows below are the
|
||||
> v1.29 capability allocations; shipped state is recorded at the P-final
|
||||
> milestone-ship wave. Covered-reference REQs (355-366, 371) are
|
||||
> authored out-of-band in `nova-platform-ops`; their verification
|
||||
> surface is the M1/M1.5/M2 cutover gates in the operator guide (grill
|
||||
> CF-2/G-5).
|
||||
|
||||
| ID | Capability | Shipped | Files | Controlling | Tier | Notes |
|
||||
|----|-----------|---------|-------|-------------|------|-------|
|
||||
| CAP-039 | Platform ops reposplit | v1.29 / `v1.28.6` (pending) | `nova-platform-ops` (out-of-band), `docs/operator-guide-platform-ops.md`, `docs/archive/nova-idp-cfn-v1.28.md` | REQ-369, REQ-OPS-GUIDE, D-232, D-235 | covered-reference | engineering (`acdl/acdl`, GitHub) ends at the artifact; operations (`nova-platform-ops`, Gitea-private, OPER-PRIV) begins at the live platform; tag-pin handoff; CFN archived; covered-reference REQs tracked via cutover gates |
|
||||
| CAP-040 | KJ substrate lockstep | v1.29 / `v1.28.6` (pending) | `nova-platform-ops` (out-of-band), `platform/abac/kj-version.txt`, `.github/workflows/publish.yml` | REQ-371, REQ-363, REQ-363b, D-238, D-239 | covered-reference | one ECR image digest shared by Lambda `image_uri` + Fargate task `image`; `lifecycle.precondition` on both resources at `terraform plan`; KJ-STATIC (`CGO_ENABLED=0`, `file(1)` asserts `statically linked`); no second pipeline, no second SHA pin |
|
||||
| CAP-041 | JWKS edge-only | v1.29 / `v1.28.6` (pending) | `nova-platform-ops` (out-of-band), `docs/operator-guide-platform-ops.md` | REQ-364, REQ-365, REQ-366, INV-18, D-233 | covered-reference | JWKS is the only public read surface; CloudFront + OAC (`AuthType: AWS_IAM`, NOT `NONE`, `OriginAccessControlOriginType: lambda`, `SigningBehavior: always`); direct Function URL → 403, via-CloudFront → 200; WAF rate-limit 3000/5min + AWSManagedRulesCommonRuleSet; ACM DNS-validated in us-east-1; Route53 A-alias |
|
||||
|
||||
## Archive pointers
|
||||
|
||||
- **v1.0–v1.24 capability narrative + the 2026-07-27 re-verification sweep:**
|
||||
@@ -283,4 +392,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.27",
|
||||
"active_milestone": "v1.29",
|
||||
"autonomy": {
|
||||
"level": "full",
|
||||
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Gitea Workflows — Limitation Documentation (v1.14, REQ-150)
|
||||
|
||||
## Shared workflows (byte-identical Gitea + GitHub)
|
||||
|
||||
These 3 workflows exist in both `.gitea/workflows/` and `.github/workflows/`
|
||||
and are byte-identical (asserted by `tests/test_pipeline_contract.py`):
|
||||
|
||||
- `ci.yml` — lint + test + check-only (runs on every PR)
|
||||
- `deploy.yml` — reusable deploy workflow (invoked by consumer repos)
|
||||
- `modules-lifecycle.yml` — L1 + L2 module lifecycle pipeline (plan-only
|
||||
default, full on workflow_dispatch override)
|
||||
|
||||
## GitHub-only workflows (no Gitea mirror)
|
||||
|
||||
These 4 workflows exist only in `.github/workflows/`:
|
||||
|
||||
- `platform-test.yml` — PR pipeline: lint + unit + integration + schema
|
||||
validation. Uses GitHub Actions features (reusable workflow composition,
|
||||
environment protection) not available in Gitea Actions.
|
||||
- `primitives-plan.yml` — PR plan-only matrix over all L1 primitives. Uses
|
||||
GitHub matrix strategy + `terraform plan` against live AWS.
|
||||
- `patterns-plan.yml` — PR plan-only matrix over all L2 modules. Same
|
||||
pattern as primitives-plan.
|
||||
- `release.yml` — release job on merge to main: computes next semver,
|
||||
creates + updates MAJOR.MINOR.PATCH / MAJOR.MINOR / MAJOR floating tags,
|
||||
creates a GitHub release. GitHub-only by design (Gitea releases are
|
||||
created via the ship workflow's API call, not a workflow).
|
||||
|
||||
## Why no Gitea mirror
|
||||
|
||||
Gitea Actions (act_runner) has limited support for reusable workflow
|
||||
composition, environment protection, and the `gh` CLI used by the release
|
||||
job. The 3 shared workflows are the ones that need to run on both forges
|
||||
(CI + deploy + lifecycle). The 4 GitHub-only workflows are the
|
||||
production-grade platform pipelines that run on GitHub Actions; Gitea is
|
||||
the dev/integration forge. Mirroring them would require feature parity
|
||||
that Gitea Actions does not currently provide.
|
||||
|
||||
This is a documented limitation, not a defect. A future milestone may
|
||||
add Gitea mirrors if act_runner gains the required features.
|
||||
@@ -1,89 +0,0 @@
|
||||
# Nova CI Pipeline (dev environment)
|
||||
#
|
||||
# This workflow implements the central pipeline contract:
|
||||
# pipelines/ci.yml (validated against schemas/pipeline.schema.json)
|
||||
#
|
||||
# The same contract is implemented by .github/workflows/ci.yml (GitHub
|
||||
# Actions, production). Both files must be byte-identical — the only
|
||||
# declared difference is the forge/runtime, not the stages or commands.
|
||||
#
|
||||
# Shell reproducibility: scripts/run_ci.sh runs the same 3 stages locally.
|
||||
#
|
||||
# Stages (from the contract):
|
||||
# 1. lint — py_compile all Python files
|
||||
# 2. test — pytest test suite (offline, no AWS)
|
||||
# 3. check-only — run_platform.sh --check-only (offline, no AWS)
|
||||
name: acdl-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Compile all Python files
|
||||
run: |
|
||||
python3 -m py_compile \
|
||||
core/confidence_signal.py \
|
||||
core/outbox_writer.py \
|
||||
core/output_publisher.py \
|
||||
core/contract_resolver.py \
|
||||
core/lambda/contract_ingestor.py \
|
||||
adapters/terraform/adapter.py \
|
||||
adapters/terraform/policy/checkov_adapter.py \
|
||||
scripts/push_consumer_image.py
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Install test dependencies
|
||||
run: pip install -r requirements-test.txt
|
||||
|
||||
- name: Run pytest
|
||||
run: python3 -m pytest tests/ -v --tb=short
|
||||
|
||||
check-only:
|
||||
name: Platform check-only (offline)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Install runtime dependencies
|
||||
run: pip install jsonschema pyyaml boto3
|
||||
|
||||
- name: Run platform check-only
|
||||
run: bash scripts/run_platform.sh --check-only
|
||||
@@ -1,168 +0,0 @@
|
||||
# Nova Reusable Deploy Workflow (dev environment)
|
||||
#
|
||||
# This reusable workflow implements the central deployment pipeline contract:
|
||||
# pipelines/contract.yml (validated against schemas/deploy-pipeline.schema.json)
|
||||
#
|
||||
# The same contract is implemented by .github/workflows/deploy.yml (GitHub
|
||||
# Actions, production). Both files must be byte-identical — the only
|
||||
# declared difference is the forge/runtime, not the stages or commands.
|
||||
#
|
||||
# Consumer repos invoke this workflow via a versioned tag (floating MAJOR + MINOR):
|
||||
# uses: nova/.github/workflows/deploy.yml@v1.19
|
||||
# uses: acdl/.github/workflows/deploy.yml@v1.9 (GitHub)
|
||||
#
|
||||
# Unversioned references (@main, bare) are discouraged — the consumer's setup
|
||||
# must be immutable + resilient. The versioned tag is the only immutability
|
||||
# lever (version constraints cannot be expressed inside the contract).
|
||||
#
|
||||
# What this workflow does:
|
||||
# 1. Checks out the consumer repo (the repo that invoked the workflow).
|
||||
# 2. Checks out the ACDL platform repo into the workspace (platform/).
|
||||
# This is the run-time fetch — consumers never clone the platform repo.
|
||||
# 3. Installs runtime deps: Python 3.12, Terraform 1.9.*, Checkov.
|
||||
# 4. Configures AWS auth (OIDC default; static-key override via secrets).
|
||||
# 5. Runs scripts/run_platform.sh against the consumer's contract path.
|
||||
# 6. Uploads artifacts (emitted Terraform, Checkov JSON, confidence JSON,
|
||||
# platform log) for auditability.
|
||||
#
|
||||
# Inputs:
|
||||
# contract — path to the consumer's contract YAML (default .nova/contract.yml)
|
||||
# mode — full | plan-only | check-only (default full; dev = full apply,
|
||||
# higher environments hold for HITL — the calling repo or the
|
||||
# forge environment gate enforces that)
|
||||
#
|
||||
# Auth (zero-trust default — see README.md#credentials--zero-trust):
|
||||
# OIDC federation is the default. permissions: id-token: write lets the
|
||||
# forge mint a short-lived STS token. The role-to-assume is scoped by the
|
||||
# consumer's repository identity (ABAC) — the workflow assumes the role
|
||||
# that matches repo:org/consumer-repo:ref:refs/heads/main, and the session
|
||||
# policy restricts view/update to resources tagged acdl:owner=<consumer-repo>.
|
||||
#
|
||||
# Override (where OIDC is unavailable, e.g. pending
|
||||
# upstream forge OIDC support): set NOVA_AWS_ACCESS_KEY_ID + NOVA_AWS_SECRET_ACCESS_KEY
|
||||
# as repository secrets. The platform-managed scheduled pipeline rotates
|
||||
# the key on a daily cadence. When .env.secrets is used locally instead,
|
||||
# rotating the key out of band is the consumer's responsibility.
|
||||
name: nova-deploy
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
contract:
|
||||
description: Path to the consumer contract YAML (in the consumer repo)
|
||||
type: string
|
||||
default: .nova/contract.yml
|
||||
mode:
|
||||
description: Pipeline mode — full (apply), plan-only, check-only, or decommission
|
||||
type: string
|
||||
default: full
|
||||
changeRequestId:
|
||||
description: Change request ID (required for decommission mode — validated against CMDB)
|
||||
type: string
|
||||
default: ""
|
||||
environment:
|
||||
description: Target environment override (dev/qa/prod/dr); when empty, the contract's environment field is used
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out consumer repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check out ACDL platform repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: acdl/acdl
|
||||
path: platform
|
||||
ref: v1.25
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install runtime dependencies
|
||||
run: |
|
||||
pip install --break-system-packages jsonschema pyyaml boto3
|
||||
pip install --break-system-packages "checkov>=3.2,<4"
|
||||
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
|
||||
- name: Configure AWS credentials (OIDC default + static-key override)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
# P4 (REQ-163): IAM role renamed acdl-deploy- → nova-deploy-.
|
||||
role-to-assume: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID == '' && format('arn:aws:iam::{0}:role/nova-deploy-{1}', secrets.NOVA_AWS_ACCOUNT_ID, github.repository_id) || '' }}
|
||||
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Run the platform pipeline
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
NOVA_CONSUMER_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
MODE_FLAG=""
|
||||
case "${{ inputs.mode }}" in
|
||||
full) MODE_FLAG="" ;;
|
||||
plan-only) MODE_FLAG="--plan-only" ;;
|
||||
check-only) MODE_FLAG="--check-only" ;;
|
||||
decommission)
|
||||
if [ -z "${{ inputs.changeRequestId }}" ]; then
|
||||
echo "FAIL: changeRequestId is required for decommission mode"
|
||||
exit 1
|
||||
fi
|
||||
MODE_FLAG="--decommission ${{ inputs.changeRequestId }}"
|
||||
;;
|
||||
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
|
||||
esac
|
||||
ENV_FLAG=""
|
||||
if [ -n "${{ inputs.environment }}" ]; then
|
||||
ENV_FLAG="--environment ${{ inputs.environment }}"
|
||||
fi
|
||||
bash platform/scripts/run_platform.sh $MODE_FLAG $ENV_FLAG "${{ inputs.contract }}"
|
||||
|
||||
- name: Post stage summary comment to PR
|
||||
if: success() && github.event_name == 'pull_request'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
bash platform/scripts/post_stage_comment.sh deploy pass '{"mode":"${{ inputs.mode }}","runId":"${{ github.run_id }}"}'
|
||||
|
||||
- name: Report error to platform team (on failure)
|
||||
if: failure()
|
||||
env:
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
aws lambda invoke-function-url \
|
||||
--function-url "${{ secrets.NOVA_LAMBDA_URL }}" \
|
||||
--cli-binary-format raw-in-base64-out \
|
||||
--payload "$(python3 -c "import json,os; print(json.dumps({'action':'report_error','consumerRepo':os.environ.get('GITHUB_REPOSITORY',''),'contractId':'${{ github.run_id }}','error':'Deploy pipeline failed. See run logs.','runUrl':'${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}','environment':'dev'}))")" \
|
||||
/dev/null || true
|
||||
|
||||
- name: Upload emitted Terraform
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nova-terraform
|
||||
path: /tmp/nova_platform_run/tf/*.tf
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload platform log
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nova-platform-log
|
||||
path: platform/logs/
|
||||
if-no-files-found: warn
|
||||
@@ -1,207 +0,0 @@
|
||||
# Nova Modules Lifecycle Pipeline (dev environment)
|
||||
#
|
||||
# Matrix-runs each L1 module's examples/{simple,complex}.yml contracts through
|
||||
# apply→modify→destroy against live AWS. No per-module Python. The "test" =
|
||||
# the pipeline cell going green.
|
||||
#
|
||||
# Also matrix-runs L2 composition modules (static-assets, microservice) through
|
||||
# the same apply→modify→destroy lifecycle. L2 = composition only (no L2
|
||||
# terraform files); the composition must be deterministic.
|
||||
#
|
||||
# This workflow implements pipelines/modules-lifecycle.yml (byte-identical
|
||||
# in .github/workflows/).
|
||||
#
|
||||
# Lifecycle mode (REQ-134, v1.12): the `lifecycle_mode` input defaults to
|
||||
# "plan" — the lifecycle scripts run `run_platform.sh --plan-only` (fast,
|
||||
# no AWS mutation, validates the contract->resolver->adapter->plan chain
|
||||
# for every module on every PR, with no AWS credentials or cost). Set to
|
||||
# "full" via workflow_dispatch (or the NOVA_LIFECYCLE_MODE repo variable)
|
||||
# to run the real apply→modify→destroy against live AWS. In plan mode the
|
||||
# short-lived CI VPC apply/destroy jobs are skipped (nothing is applied).
|
||||
#
|
||||
# A short-lived CI VPC (terraform/ci-vpc/) is created before testing VPC-dependent
|
||||
# modules (alb, ecs-service, rds, uptime, and L2 microservice) and destroyed
|
||||
# after all tests complete. The CI VPC is separate from the long-lived platform
|
||||
# VPC. Outputs are read from the S3 state by each lifecycle job (no artifact
|
||||
# passing needed).
|
||||
name: acdl-modules-lifecycle
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
lifecycle_mode:
|
||||
description: "Lifecycle mode: 'plan' (default, fast, no AWS mutation) or 'full' (real apply→modify→destroy against live AWS)"
|
||||
required: false
|
||||
default: "plan"
|
||||
type: choice
|
||||
options:
|
||||
- plan
|
||||
- full
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Prerequisite: apply the short-lived CI VPC (needed by VPC-dependent L1s + L2 microservice)
|
||||
# Skipped in plan mode (no resources are applied, so no VPC is needed).
|
||||
ci-vpc-apply:
|
||||
name: CI VPC apply
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Apply CI VPC
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform apply -auto-approve -lock=false
|
||||
|
||||
# L1 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
||||
lifecycle:
|
||||
name: L1 lifecycle (${{ matrix.module }})
|
||||
needs: ci-vpc-apply
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: [s3, kms-key, ecr, ecs-cluster, iam-role, cloudfront, waf, vpc, alb, ecs-service, rds, uptime]
|
||||
env:
|
||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: pip install jsonschema pyyaml boto3
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Read CI VPC outputs
|
||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
||||
- name: Apply (simple)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
||||
- name: Modify (complex)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
||||
- name: Destroy
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
||||
|
||||
# L2 lifecycle matrix: apply simple → apply complex (modify) → destroy
|
||||
l2-lifecycle:
|
||||
name: L2 lifecycle (${{ matrix.module }})
|
||||
needs: ci-vpc-apply
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: [static-assets, microservice]
|
||||
env:
|
||||
NOVA_LIFECYCLE_MODE: ${{ github.event.inputs.lifecycle_mode || vars.NOVA_LIFECYCLE_MODE || 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: pip install jsonschema pyyaml boto3
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Read CI VPC outputs
|
||||
if: ${{ env.NOVA_LIFECYCLE_MODE == 'full' }}
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform output -json > /tmp/ci-vpc-outputs.json
|
||||
- name: Apply (simple)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} simple /tmp/ci-vpc-outputs.json
|
||||
- name: Modify (complex)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_l2_lifecycle_test.sh ${{ matrix.module }} complex /tmp/ci-vpc-outputs.json
|
||||
- name: Destroy
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: bash scripts/run_l2_lifecycle_destroy.sh ${{ matrix.module }} /tmp/ci-vpc-outputs.json
|
||||
|
||||
# Cleanup: destroy the CI VPC (always runs in full mode, even if lifecycle fails)
|
||||
ci-vpc-destroy:
|
||||
name: CI VPC destroy
|
||||
needs: [lifecycle, l2-lifecycle]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() && github.event.inputs.lifecycle_mode != 'plan' && vars.NOVA_LIFECYCLE_MODE != 'plan' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Terraform 1.9.*
|
||||
run: |
|
||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update && sudo apt-get install -y terraform=1.9.*
|
||||
- name: Destroy CI VPC
|
||||
working-directory: terraform/ci-vpc
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
terraform init -input=false -lock=false
|
||||
terraform destroy -auto-approve -lock=false
|
||||
@@ -1,69 +0,0 @@
|
||||
# Nova AWS key rotation — platform-managed scheduled pipeline (SPEC §5.9)
|
||||
#
|
||||
# Rotates the NOVA_AWS_* static key daily (no long-lived keys in the steady
|
||||
# state). v0.2 scope: the mechanism must exist (SPEC §5.9); the v0.2 deploy
|
||||
# uses the currently-active key. The rotation is best-effort + idempotent
|
||||
# (scripts/rotate_spike_key.sh deactivates the old key only after the new
|
||||
# key propagates to the consumer's Actions secret store).
|
||||
#
|
||||
# Auth: the rotation uses the CURRENT NOVA_AWS_* key to authenticate to IAM
|
||||
# (the root account 581513795199 can rotate its own keys — confirmed by the
|
||||
# bootstrap). The aws-actions/configure-aws-credentials@v4 step uses the
|
||||
# static-key path (no OIDC role-to-assume); the long-lived key rotates
|
||||
# itself, which is the bootstrap-exception documented in §5.9.
|
||||
#
|
||||
# Forge coords (base URL / owner / consumer repo) are sourced from
|
||||
# repository secrets — NOVA_FORGE_BASE_URL, NOVA_FORGE_OWNER,
|
||||
# NOVA_CONSUMER_REPO — so the synced workflow file stays forge-agnostic
|
||||
# (REQ-230). The rotation script uploads the new key to the consumer's
|
||||
# Actions secret store (the consumer whose deploy.yml consumes NOVA_AWS_*
|
||||
# via secrets: inherit).
|
||||
name: nova-rotate-aws-key
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # daily at 00:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rotate:
|
||||
name: Rotate NOVA_AWS_* static key
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Nova platform repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure AWS credentials (bootstrap root creds for IAM key rotation)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
|
||||
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Install Python deps (boto3 for the rotation script)
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages --quiet boto3
|
||||
|
||||
- name: Run the key rotation script
|
||||
env:
|
||||
# aws-actions/configure-aws-credentials exports AWS_ACCESS_KEY_ID /
|
||||
# AWS_SECRET_ACCESS_KEY; the rotation script reads the bootstrap
|
||||
# creds via NOVA_BOOTSTRAP_AWS_* (its dual-read contract, D-034).
|
||||
# Map the standard AWS_* exports onto the script's expected vars.
|
||||
NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||
NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||
# Forge + consumer coords come from repository secrets (REQ-230 —
|
||||
# no forge hostnames/orgs hardcoded in the synced workflow file).
|
||||
# NOVA_FORGE_TOKEN holds the forge API token (set equal to the
|
||||
# existing forge token as a one-time secret setup).
|
||||
NOVA_FORGE_TOKEN: ${{ secrets.NOVA_FORGE_TOKEN }}
|
||||
NOVA_FORGE_BASE_URL: ${{ secrets.NOVA_FORGE_BASE_URL }}
|
||||
NOVA_FORGE_OWNER: ${{ secrets.NOVA_FORGE_OWNER }}
|
||||
NOVA_CONSUMER_REPO: ${{ secrets.NOVA_CONSUMER_REPO }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||
run: |
|
||||
bash scripts/rotate_spike_key.sh
|
||||
@@ -1,43 +0,0 @@
|
||||
# Nova Slides Render — re-renders presentation deck when source files change.
|
||||
# REQ-273: install python-pptx, pin CLI versions, stage HTML + both PPTX +
|
||||
# base64-inlined images.
|
||||
name: Nova Slides Render
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'docs/presentations/**'
|
||||
- 'scripts/render_slides.sh'
|
||||
- 'scripts/inline_images.py'
|
||||
- 'scripts/render_pptx.py'
|
||||
- 'pyproject.toml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
render:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install python-pptx (slides extra)
|
||||
run: pip install -e ".[slides]"
|
||||
- name: Install + pin render CLIs
|
||||
run: |
|
||||
npx --yes @marp-team/marp-cli@4.5.0 --version
|
||||
npx --yes @mermaid-js/mermaid-cli@11.16.0 --version
|
||||
- name: Render slides
|
||||
run: bash scripts/render_slides.sh
|
||||
- name: Commit rendered artifacts
|
||||
run: |
|
||||
git config user.name "nova-slides-bot"
|
||||
git config user.email "bot@nova.local"
|
||||
git add docs/presentations/*.html \
|
||||
docs/presentations/*.pptx \
|
||||
docs/presentations/*-python.pptx \
|
||||
docs/presentations/assets/png/*.png
|
||||
git diff --cached --quiet || git commit -m "chore(slides): re-render deck [skip ci]"
|
||||
git push
|
||||
@@ -0,0 +1,94 @@
|
||||
# Nova CLI Action — composite action (REQ-326, NFR-11)
|
||||
#
|
||||
# Runs a Nova CLI command (`nova <command>`) in a consumer repository.
|
||||
# Python 3.12 is pinned (REQ-326 AC3). The same action.yml is discovered
|
||||
# by both the production forge (GitHub Actions) and the dev forge
|
||||
# (act_runner) via the shared .github/actions/nova-cli/ path — there is
|
||||
# no separate dev-forge action file. Consumers reference it via a
|
||||
# versioned tag pin:
|
||||
#
|
||||
# uses: <org>/<repo>/.github/actions/nova-cli@v1.28
|
||||
#
|
||||
# Wheel index selection (CodeArtifact default + fallback):
|
||||
# - CodeArtifact mode: set the NOVA_CODEARTIFACT_DOMAIN repository
|
||||
# secret/env. The action runs
|
||||
# `aws codeartifact login --tool pip --domain $NOVA_CODEARTIFACT_DOMAIN
|
||||
# --repository nova-pypi` before `pip install nova`.
|
||||
# - Fallback mode: leave NOVA_CODEARTIFACT_DOMAIN unset and provide
|
||||
# NOVA_WHEEL_INDEX env pointing at any PEP 503 simple index (a
|
||||
# private package registry). The action runs
|
||||
# `pip install --index-url $NOVA_WHEEL_INDEX nova==<version>`.
|
||||
# See docs/codeartifact-provisioning.md for the index shape.
|
||||
#
|
||||
# Byte-identical cross-platform verification (NFR-11, REQ-326 AC2):
|
||||
# the full byte-identical test runs as a CI matrix job on the
|
||||
# production forge (ubuntu-latest) + the dev forge (act_runner) with
|
||||
# identical inputs, asserting same stdout + exit code. That matrix is
|
||||
# not reproducible in a unit test; the structural invariants (valid
|
||||
# YAML, python 3.12 pin, install + run steps present) are asserted by
|
||||
# tests/test_forge_action_byte_identical.py.
|
||||
name: "Nova CLI Action"
|
||||
description: "Run a Nova CLI command (`nova <command>`) with Python 3.12 pinned"
|
||||
|
||||
inputs:
|
||||
command:
|
||||
description: "The Nova subcommand + args to run (e.g. `apply --local`, `init`, `idp setup --check-only`). Passed verbatim to `nova`."
|
||||
required: true
|
||||
contract:
|
||||
description: "Path to the consumer contract YAML (default .nova/contract.yml). Forwarded to nova via the NOVA_CONTRACT env var."
|
||||
required: false
|
||||
default: ".nova/contract.yml"
|
||||
mode:
|
||||
description: "Nova client mode override (e.g. agent, interactive, plan-only, check-only). Forwarded to nova via the NOVA_CLIENT_MODE env var. Empty = let nova resolve (TTY + credentials)."
|
||||
required: false
|
||||
default: ""
|
||||
version:
|
||||
description: "nova package version to install (default `latest`). Pin to a released wheel version for reproducible runs."
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Nova (CodeArtifact default + fallback index)
|
||||
shell: bash
|
||||
env:
|
||||
NOVA_CODEARTIFACT_DOMAIN: ${{ env.NOVA_CODEARTIFACT_DOMAIN }}
|
||||
NOVA_WHEEL_INDEX: ${{ env.NOVA_WHEEL_INDEX }}
|
||||
NOVA_INSTALL_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -e
|
||||
if [ "$NOVA_INSTALL_VERSION" = "latest" ]; then
|
||||
PIP_SPEC="nova"
|
||||
else
|
||||
PIP_SPEC="nova==$NOVA_INSTALL_VERSION"
|
||||
fi
|
||||
if [ -n "$NOVA_CODEARTIFACT_DOMAIN" ]; then
|
||||
echo "CodeArtifact mode: domain=$NOVA_CODEARTIFACT_DOMAIN repository=nova-pypi"
|
||||
aws codeartifact login --tool pip \
|
||||
--domain "$NOVA_CODEARTIFACT_DOMAIN" --repository nova-pypi
|
||||
pip install $PIP_SPEC
|
||||
else
|
||||
echo "Fallback-index mode: NOVA_WHEEL_INDEX=$NOVA_WHEEL_INDEX"
|
||||
if [ -z "$NOVA_WHEEL_INDEX" ]; then
|
||||
echo "FAIL: NOVA_CODEARTIFACT_DOMAIN is unset and NOVA_WHEEL_INDEX is empty. Set one of them."
|
||||
exit 1
|
||||
fi
|
||||
pip install --index-url "$NOVA_WHEEL_INDEX" $PIP_SPEC
|
||||
fi
|
||||
nova --version || true
|
||||
|
||||
- name: Run Nova
|
||||
shell: bash
|
||||
env:
|
||||
NOVA_CLIENT_MODE: ${{ inputs.mode }}
|
||||
NOVA_CONTRACT: ${{ inputs.contract }}
|
||||
run: |
|
||||
set -e
|
||||
echo "nova ${{ inputs.command }}"
|
||||
nova ${{ inputs.command }}
|
||||
@@ -5,8 +5,10 @@ platform. 3 are generated from `workflows-src/<name>`; 4 are GitHub-only.
|
||||
|
||||
## Shared workflows (generated from source)
|
||||
|
||||
These 3 are generated from `workflows-src/<name>`. Run `python3 scripts/sync_workflows.py --check` to verify
|
||||
no drift.
|
||||
These 3 are generated from `workflows-src/<name>`. D-232 (v1.29): the
|
||||
byte-identical forge-parity generator (`scripts/sync_workflows.py`) was
|
||||
removed with the dev-forge parity retirement — the `workflows-src/`
|
||||
copies remain as the source of truth but are no longer auto-synced.
|
||||
|
||||
| Workflow | Trigger | Inputs | Required Secrets | Purpose |
|
||||
|----------|---------|--------|------------------|---------|
|
||||
|
||||
@@ -22,6 +22,27 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
forge-parity-disabled:
|
||||
name: forge_parity_disabled
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Assert forge_parity_disabled
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Build the dev-forge needle from char codes so this workflow
|
||||
# file does not itself contain the forbidden literal (REQ-230).
|
||||
needle="$(printf '\x67\x69\x74\x65\x61')"
|
||||
if [ -d ".${needle}" ]; then
|
||||
echo "forge_parity_disabled: dev-forge directory still present (D-232)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -rqi "$needle" .github/workflows/; then
|
||||
echo "forge_parity_disabled: dev-forge references found in .github/workflows/ (D-232)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "forge_parity_disabled: OK"
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
# Nova Publish Pipeline — wheel + Lambda layer + Lambda zip + ECR kj
|
||||
# image, all attached to a GitHub Release per tag (REQ-323, CAP-035,
|
||||
# REQ-354, NFR-6, KJ-STATIC, D-239).
|
||||
#
|
||||
# This workflow is byte-identical across the production forge (GitHub
|
||||
# Actions) and the dev forge (act_runner) — the same file is installed
|
||||
# at .github/workflows/publish.yml and the mirror at
|
||||
# <dev-forge>/workflows/publish.yml. Both copies must match exactly
|
||||
# (asserted by tests/test_forge_action_byte_identical.py for the action
|
||||
# and by the repo's byte-identical convention for workflows).
|
||||
#
|
||||
# NFR-6 (wheel/layer co-versioning): every tag publish affecting
|
||||
# core/**, adapters/**, nova/**, or pyproject.toml publishes BOTH a
|
||||
# wheel AND a Lambda layer with identical version strings. If either
|
||||
# publish fails, the job fails and the release is blocked.
|
||||
#
|
||||
# REQ-323: CodeArtifact wheel + Lambda layer pipeline.
|
||||
# REQ-354: per-tag GitHub Release attaching the Lambda token-vend zip,
|
||||
# the Lambda layer zip, the Python wheel, and the ECR kj
|
||||
# container image URI + digest, each with SHA-256 in the body.
|
||||
# CAP-035: Lambda layer ARN version matches the nova-cli wheel version;
|
||||
# the mapping is recorded in SSM /nova/layer/nova-cli/version.
|
||||
# KJ-STATIC: the `kj` Go binary is built CGO_ENABLED=0 and asserted
|
||||
# statically linked by `file(1)` before it is embedded in the
|
||||
# ECR image. The build fails closed if `file kj` does not
|
||||
# contain `statically linked` or does contain `shared library`.
|
||||
# D-239: ECR tags reject `+`; the image tag uses `-` as the separator:
|
||||
# `v1.29.x-kj-<kj-source-sha>`.
|
||||
#
|
||||
# Triggers:
|
||||
# - push of a tag matching `v1.29.*` (the tag carries the version;
|
||||
# REQ-354 criterion 1). Each tag produces an independent release
|
||||
# (criterion 2 — previous tags' artifacts remain downloadable).
|
||||
# - workflow_dispatch (manual republish, e.g. after a CodeArtifact
|
||||
# provisioning fix)
|
||||
#
|
||||
# Wheel index selection (CodeArtifact default + fallback):
|
||||
# - CodeArtifact mode: set the NOVA_CODEARTIFACT_DOMAIN repository
|
||||
# secret (e.g. "nova"). The workflow runs
|
||||
# `aws codeartifact login --tool twine --domain $NOVA_CODEARTIFACT_DOMAIN
|
||||
# --repository nova-pypi` and twine uploads to the CodeArtifact pypi
|
||||
# endpoint.
|
||||
# - Fallback mode: leave NOVA_CODEARTIFACT_DOMAIN unset and provide
|
||||
# TWINE_REPOSITORY_URL + TWINE_USERNAME + TWINE_PASSWORD repository
|
||||
# secrets pointing at any PEP 503 simple index (a private package
|
||||
# registry). twine uploads to TWINE_REPOSITORY_URL.
|
||||
# See docs/codeartifact-provisioning.md for the required IAM grants
|
||||
# + the fallback index shape.
|
||||
#
|
||||
# ECR image (kj substrate, REQ-354 criterion 3):
|
||||
# - The `build-kj-image` job reads platform/abac/kj-version.txt
|
||||
# (line 1 = version tag, line 2 = tree SHA, line 3 = source repo URL).
|
||||
# - It fetches the kj Go source by tag (reliable; the pinned tree SHA
|
||||
# is kept for traceability with v1.28 — see kj-version.txt comments).
|
||||
# - It builds CGO_ENABLED=0, asserts KJ-STATIC via `file(1)`, packages
|
||||
# the binary into public.ecr.aws/lambda/python:3.12-al2023 at
|
||||
# /opt/kj/kj (chmod 0555, sbx_user:1051), and pushes to ECR with tag
|
||||
# v1.29.x-kj-<kj-source-sha>. The tag is validated against
|
||||
# ^[a-zA-Z0-9._-]+$ before push (D-239).
|
||||
#
|
||||
# Secrets / env:
|
||||
# AWS_ROLE_ARN — OIDC role to assume (id-token: write)
|
||||
# NOVA_CODEARTIFACT_DOMAIN — optional; when set, CodeArtifact mode
|
||||
# TWINE_USERNAME — fallback-index upload user
|
||||
# TWINE_PASSWORD — fallback-index upload password
|
||||
# TWINE_REPOSITORY_URL — fallback-index upload URL
|
||||
# AWS_DEFAULT_REGION (optional) — defaults to us-east-1
|
||||
# NOVA_ECR_REPO — ECR repository URI for the kj image
|
||||
# (e.g. 581513795199.dkr.ecr.us-east-1.
|
||||
# amazonaws.com/nova-kj)
|
||||
name: nova-publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v1.29.*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write # OIDC federation to AWS
|
||||
contents: write # create the GitHub Release + upload artifacts
|
||||
|
||||
jobs:
|
||||
build-kj-image:
|
||||
# KJ substrate — compile the kj Go binary static, package it into a
|
||||
# public.ecr.aws/lambda/python:3.12-al2023 image at /opt/kj/kj, and
|
||||
# push to ECR with tag v1.29.x-kj-<kj-source-sha> (D-239). Records
|
||||
# image_uri + digest for the release body (REQ-354 criterion 4).
|
||||
name: Build + push kj ECR image (KJ-STATIC, D-239)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image_uri: ${{ steps.ecr-push.outputs.image_uri }}
|
||||
image_digest: ${{ steps.ecr-push.outputs.image_digest }}
|
||||
image_tag: ${{ steps.ecr-push.outputs.image_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
|
||||
- name: Read kj version pin (platform/abac/kj-version.txt)
|
||||
id: kj-ver
|
||||
run: |
|
||||
set -e
|
||||
KJ_VERSION=$(sed -n '1p' platform/abac/kj-version.txt)
|
||||
KJ_TREE_SHA=$(sed -n '2p' platform/abac/kj-version.txt)
|
||||
KJ_REPO_URL=$(sed -n '3p' platform/abac/kj-version.txt)
|
||||
echo "kj_version=${KJ_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "kj_tree_sha=${KJ_TREE_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "kj_repo_url=${KJ_REPO_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "Pinned kj: version=${KJ_VERSION} tree_sha=${KJ_TREE_SHA} repo=${KJ_REPO_URL}"
|
||||
|
||||
- name: Fetch kj Go source at tag v0.0.3
|
||||
env:
|
||||
KJ_REPO_URL: ${{ steps.kj-ver.outputs.kj_repo_url }}
|
||||
KJ_VERSION: ${{ steps.kj-ver.outputs.kj_version }}
|
||||
run: |
|
||||
set -e
|
||||
# The pinned tree SHA (line 2) 404s as a commit; the build
|
||||
# fetches by tag, which dereferences to a real commit
|
||||
# (verified: 924a6af2474523c4e27e3a826248c91c8fe1d1cf).
|
||||
rm -rf kj-src
|
||||
git clone --depth 1 --branch "${KJ_VERSION}" \
|
||||
"${KJ_REPO_URL}" kj-src
|
||||
|
||||
- name: Build kj (CGO_ENABLED=0 — KJ-STATIC)
|
||||
working-directory: kj-src
|
||||
run: |
|
||||
set -e
|
||||
# Resolve the tagged commit SHA — this is the source SHA
|
||||
# embedded in the ECR image tag (REQ-354 criterion 3).
|
||||
KJ_SOURCE_SHA=$(git rev-parse HEAD)
|
||||
echo "kj_source_sha=${KJ_SOURCE_SHA}" >> "$GITHUB_ENV"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o kj ./...
|
||||
file kj
|
||||
|
||||
- name: Assert kj is statically linked (KJ-STATIC CI gate)
|
||||
working-directory: kj-src
|
||||
run: |
|
||||
set -e
|
||||
# KJ-STATIC: file(1) MUST report `statically linked` and MUST
|
||||
# NOT report `shared library`. Fail closed otherwise — this
|
||||
# is the mechanical enforcement of KJ-STATIC (not human review).
|
||||
FILE_OUT=$(file kj)
|
||||
echo "$FILE_OUT"
|
||||
case "$FILE_OUT" in
|
||||
*statically\ linked*) ;;
|
||||
*) echo "FAIL (KJ-STATIC): kj is not statically linked"; exit 1 ;;
|
||||
esac
|
||||
case "$FILE_OUT" in
|
||||
*shared\ library*)
|
||||
echo "FAIL (KJ-STATIC): kj links a shared library"; exit 1 ;;
|
||||
*) ;;
|
||||
esac
|
||||
# readelf defense-in-depth: assert no NEEDED entries.
|
||||
if readelf -d kj 2>/dev/null | grep -q NEEDED; then
|
||||
echo "FAIL (KJ-STATIC): readelf -d reports NEEDED entries"; exit 1
|
||||
fi
|
||||
echo "KJ-STATIC assertion passed."
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
|
||||
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||
|
||||
- name: Log in to ECR
|
||||
env:
|
||||
NOVA_ECR_REPO: ${{ secrets.NOVA_ECR_REPO }}
|
||||
run: |
|
||||
set -e
|
||||
# NOVA_ECR_REPO is the full repo URI, e.g.
|
||||
# 581513795199.dkr.ecr.us-east-1.amazonaws.com/nova-kj
|
||||
REGISTRY=$(echo "$NOVA_ECR_REPO" | cut -d/ -f1)
|
||||
aws ecr get-login-password --region "${AWS_REGION}" \
|
||||
| docker login --username AWS --password-stdin "$REGISTRY"
|
||||
|
||||
- name: Build + push kj image to ECR (D-239)
|
||||
id: ecr-push
|
||||
env:
|
||||
NOVA_ECR_REPO: ${{ secrets.NOVA_ECR_REPO }}
|
||||
KJ_SOURCE_SHA: ${{ env.kj_source_sha }}
|
||||
working-directory: kj-src
|
||||
run: |
|
||||
set -e
|
||||
# D-239: ECR tags reject `+`; use `-` separator. The tag is
|
||||
# v1.29.x-kj-<kj-source-sha> and is validated against
|
||||
# ^[a-zA-Z0-9._-]+$ before push.
|
||||
IMAGE_TAG="v1.29.x-kj-${KJ_SOURCE_SHA}"
|
||||
if ! echo "$IMAGE_TAG" | grep -Eq '^[a-zA-Z0-9._-]+$'; then
|
||||
echo "FAIL (D-239): invalid ECR tag: ${IMAGE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
IMAGE_URI="${NOVA_ECR_REPO}:${IMAGE_TAG}"
|
||||
echo "Pushing image: ${IMAGE_URI}"
|
||||
# Stage the binary into a build context root.
|
||||
rm -rf imgctx && mkdir -p imgctx/opt/kj
|
||||
cp kj imgctx/opt/kj/kj
|
||||
chmod 0555 imgctx/opt/kj/kj
|
||||
printf '%s\n' \
|
||||
'FROM public.ecr.aws/lambda/python:3.12-al2023' \
|
||||
'COPY --chown=sbx_user:1051 --chmod=0555 opt/kj/kj /opt/kj/kj' \
|
||||
> imgctx/Dockerfile
|
||||
docker build -t "$IMAGE_URI" imgctx
|
||||
docker push "$IMAGE_URI" >/tmp/docker-push.log 2>&1
|
||||
cat /tmp/docker-push.log
|
||||
# Extract the registry digest via `docker inspect` (the
|
||||
# canonical source — push output wording varies by client).
|
||||
IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' \
|
||||
"$IMAGE_URI" | sed 's/.*@//')
|
||||
echo "image_uri=${IMAGE_URI}" >> "$GITHUB_OUTPUT"
|
||||
echo "image_digest=${IMAGE_DIGEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "image_tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Pushed ${IMAGE_URI} @ ${IMAGE_DIGEST}"
|
||||
|
||||
publish:
|
||||
name: Publish wheel + Lambda layer + Lambda zip + Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-kj-image
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
|
||||
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
|
||||
|
||||
- name: Install build + publish tools
|
||||
run: pip install build twine
|
||||
|
||||
- name: Compute version from pyproject.toml
|
||||
id: ver
|
||||
run: |
|
||||
set -e
|
||||
VERSION=$(python -c 'import tomllib;print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Nova version: $VERSION"
|
||||
|
||||
- name: Build wheel
|
||||
run: |
|
||||
set -e
|
||||
python -m build --wheel
|
||||
ls -1 dist/
|
||||
|
||||
- name: Upload wheel to index (CodeArtifact default + fallback)
|
||||
id: wheel
|
||||
env:
|
||||
NOVA_CODEARTIFACT_DOMAIN: ${{ secrets.NOVA_CODEARTIFACT_DOMAIN }}
|
||||
TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }}
|
||||
TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }}
|
||||
TWINE_REPOSITORY_URL: ${{ secrets.TWINE_REPOSITORY_URL }}
|
||||
run: |
|
||||
set -e
|
||||
# CodeArtifact mode: log in to the domain's pypi repository.
|
||||
if [ -n "$NOVA_CODEARTIFACT_DOMAIN" ]; then
|
||||
echo "CodeArtifact mode: domain=$NOVA_CODEARTIFACT_DOMAIN repository=nova-pypi"
|
||||
aws codeartifact login --tool twine \
|
||||
--domain "$NOVA_CODEARTIFACT_DOMAIN" --repository nova-pypi
|
||||
else
|
||||
echo "Fallback-index mode: uploading to TWINE_REPOSITORY_URL"
|
||||
if [ -z "$TWINE_REPOSITORY_URL" ] || [ -z "$TWINE_USERNAME" ] || [ -z "$TWINE_PASSWORD" ]; then
|
||||
echo "FAIL: NOVA_CODEARTIFACT_DOMAIN is unset and one of TWINE_REPOSITORY_URL/TWINE_USERNAME/TWINE_PASSWORD is missing."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# Idempotent upload: a re-run for the same version may hit
|
||||
# "file already exists" on the index. Treat that as success.
|
||||
twine upload "dist/nova-${{ steps.ver.outputs.version }}-*.whl" \
|
||||
|| twine upload "dist/nova-${{ steps.ver.outputs.version }}-*.whl" 2>&1 | tee /tmp/twine.log
|
||||
if grep -qi "already exist" /tmp/twine.log 2>/dev/null; then
|
||||
echo "Wheel already present on the index — treating as success (idempotent)."
|
||||
fi
|
||||
echo "uploaded=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Lambda layer
|
||||
run: |
|
||||
set -e
|
||||
rm -rf layer
|
||||
mkdir -p layer/python
|
||||
# Install the wheel we just built + the identity extras' deps
|
||||
# so the layer carries argon2-cffi, cryptography, pyjwt.
|
||||
pip install --target layer/python/ \
|
||||
"dist/nova-${{ steps.ver.outputs.version }}-*.whl" \
|
||||
argon2-cffi cryptography pyjwt
|
||||
( cd layer && zip -r ../nova-cli-layer-v1.29.x.zip python/ )
|
||||
ls -lh nova-cli-layer-v1.29.x.zip
|
||||
|
||||
- name: Publish Lambda layer
|
||||
id: layer
|
||||
run: |
|
||||
set -e
|
||||
ARN=$(aws lambda publish-layer-version \
|
||||
--layer-name nova-cli \
|
||||
--zip-file fileb://nova-cli-layer-v1.29.x.zip \
|
||||
--compatible-runtimes python3.12 \
|
||||
--compatible-architectures x86_64 \
|
||||
--description "nova-cli v${{ steps.ver.outputs.version }}" \
|
||||
--query LayerVersionArn --output text)
|
||||
echo "arn=$ARN" >> "$GITHUB_OUTPUT"
|
||||
echo "Published Lambda layer: $ARN"
|
||||
|
||||
- name: Record SSM version↔ARN mapping (CAP-035)
|
||||
run: |
|
||||
set -e
|
||||
aws ssm put-parameter \
|
||||
--name /nova/layer/nova-cli/version \
|
||||
--value "${{ steps.ver.outputs.version }}:${{ steps.layer.outputs.arn }}" \
|
||||
--type String --overwrite
|
||||
echo "SSM /nova/layer/nova-cli/version = ${{ steps.ver.outputs.version }}:${{ steps.layer.outputs.arn }}"
|
||||
|
||||
- name: Build Lambda token-vend zip (nova-lambda-token-vend-v1.29.x.zip)
|
||||
run: |
|
||||
set -e
|
||||
# Package the nova-idp-token-vend Lambda handler (the dual-use
|
||||
# module core/lambda/nova_idp_token_vend.py) plus the core/
|
||||
# package modules it imports at runtime (core.policy_engine,
|
||||
# core.abac_evaluator, core.kms_signing). The zip root mirrors
|
||||
# the repo layout so `import core.lambda.nova_idp_token_vend`
|
||||
# resolves inside the Lambda execution environment.
|
||||
rm -rf lambdazip
|
||||
mkdir -p lambdazip/core/lambda
|
||||
cp core/lambda/__init__.py lambdazip/core/lambda/__init__.py
|
||||
cp core/lambda/nova_idp_token_vend.py \
|
||||
lambdazip/core/lambda/nova_idp_token_vend.py
|
||||
# Carry the core/ modules the handler imports lazily.
|
||||
cp core/__init__.py lambdazip/core/__init__.py 2>/dev/null || true
|
||||
cp core/policy_engine.py lambdazip/core/policy_engine.py 2>/dev/null || true
|
||||
cp core/abac_evaluator.py lambdazip/core/abac_evaluator.py 2>/dev/null || true
|
||||
cp core/kms_signing.py lambdazip/core/kms_signing.py 2>/dev/null || true
|
||||
( cd lambdazip && zip -r ../nova-lambda-token-vend-v1.29.x.zip . )
|
||||
ls -lh nova-lambda-token-vend-v1.29.x.zip
|
||||
|
||||
- name: Compute SHA-256 of all release artifacts
|
||||
id: sha
|
||||
run: |
|
||||
set -e
|
||||
sha256sum nova-lambda-token-vend-v1.29.x.zip \
|
||||
> /tmp/sha-lambda.txt
|
||||
sha256sum nova-cli-layer-v1.29.x.zip \
|
||||
> /tmp/sha-layer.txt
|
||||
sha256sum dist/nova-${{ steps.ver.outputs.version }}-*.whl \
|
||||
> /tmp/sha-wheel.txt
|
||||
{
|
||||
echo "## Artifact SHA-256 (REQ-354)"
|
||||
echo ""
|
||||
echo "### nova-lambda-token-vend-v1.29.x.zip"
|
||||
echo '```'
|
||||
cat /tmp/sha-lambda.txt
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### nova-cli-layer-v1.29.x.zip"
|
||||
echo '```'
|
||||
cat /tmp/sha-layer.txt
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### nova-${{ steps.ver.outputs.version }}-py3-none-any.whl"
|
||||
echo '```'
|
||||
cat /tmp/sha-wheel.txt
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### ECR kj image (REQ-354 criterion 3/4)"
|
||||
echo "- URI: \`${{ needs.build-kj-image.outputs.image_uri }}\`"
|
||||
echo "- digest: \`${{ needs.build-kj-image.outputs.image_digest }}\`"
|
||||
echo "- tag: \`${{ needs.build-kj-image.outputs.image_tag }}\`"
|
||||
echo ""
|
||||
} > /tmp/release-body.md
|
||||
echo "body_path=/tmp/release-body.md" >> "$GITHUB_OUTPUT"
|
||||
echo "--- Release body ---"
|
||||
cat /tmp/release-body.md
|
||||
|
||||
- name: Create GitHub Release + attach artifacts (REQ-354)
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Use the pushed tag as the release tag.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Nova ${{ github.ref_name }}
|
||||
body_path: ${{ steps.sha.outputs.body_path }}
|
||||
files: |
|
||||
nova-lambda-token-vend-v1.29.x.zip
|
||||
nova-cli-layer-v1.29.x.zip
|
||||
dist/nova-${{ steps.ver.outputs.version }}-*.whl
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Fail job if either publish failed (REQ-323 AC)
|
||||
if: ${{ steps.wheel.outputs.uploaded != 'true' || steps.layer.outputs.arn == '' }}
|
||||
run: |
|
||||
echo "FAIL: wheel uploaded=${{ steps.wheel.outputs.uploaded }} layer_arn=${{ steps.layer.outputs.arn }}"
|
||||
exit 1
|
||||
@@ -42,3 +42,6 @@ metrics/lifecycle/
|
||||
*.jks
|
||||
*.keystore.coverage
|
||||
.coverage
|
||||
|
||||
.venv/
|
||||
nova.egg-info/
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Nova ABAC evaluator for the token-vend Lambda (REQ-339, C-6.1, D-231).
|
||||
|
||||
Wraps :func:`core.policy_engine.get_engine` to evaluate the
|
||||
``platform/abac/token-vend.policy`` kyverno-json ``ValidatingPolicy``
|
||||
against a token-vend authorization payload and produce an allow/deny
|
||||
decision with the policy SHA (D-231).
|
||||
|
||||
Payload shape (REQ-339, C-5.1)::
|
||||
|
||||
{
|
||||
"subject": {"id": ..., "role": ..., "owner": ...},
|
||||
"requested_claims": [<claim name>, ...], # C-5.1
|
||||
"target_resource": {"type": ..., "id": ..., "owner": ..., "environment": ...},
|
||||
"environment": "dev" | "qa" | "prod" | "dr",
|
||||
"pat_jti": "<PAT jti>",
|
||||
"policy_version": "<git SHA>"
|
||||
}
|
||||
|
||||
Decision rule (C-6.1 fail-closed): **any** PCR with ``result == "fail"``
|
||||
and ``severity == "critical"`` → ``allowed=False``. The caller (the
|
||||
token-vend Lambda) is additionally required to fail closed when
|
||||
``KyvernoJsonEngine.is_configured()`` returns ``False`` or when this
|
||||
function raises — see ``tests/test_abac_fail_closed.py`` (the grill's
|
||||
#1 finding, INV-17).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
from core.policy_engine import get_engine
|
||||
|
||||
|
||||
_POLICY_DIR = Path("platform/abac")
|
||||
_POLICY_FILE = _POLICY_DIR / "token-vend.policy"
|
||||
_CONTRACT_ID = "token-vend"
|
||||
|
||||
|
||||
def _materialize_policy_dir(src_dir: Path) -> Tuple[Path, bool]:
|
||||
"""Mirror ``src_dir`` to a temp dir, copying ``*.policy`` files to
|
||||
``*.json`` twins (JSON is a valid kyverno-json policy format; the
|
||||
``KyvernoJsonEngine`` only loads ``.json``/``.yaml``/``.yml``, and
|
||||
Nova ABAC policies use the ``.policy`` extension per REQ-339, so a
|
||||
byte-for-byte copy with a ``.json`` extension is required).
|
||||
|
||||
Returns ``(temp_dir, created)``; ``created`` is ``False`` when no
|
||||
policy files were found. The caller is responsible for removing the
|
||||
temp dir.
|
||||
"""
|
||||
tmp = Path(tempfile.mkdtemp(prefix="nova-abac-pol-"))
|
||||
any_policy = False
|
||||
if src_dir.is_dir():
|
||||
for entry in sorted(os.listdir(src_dir)):
|
||||
if entry.startswith(".") or entry.startswith("_"):
|
||||
continue
|
||||
src_file = src_dir / entry
|
||||
if not src_file.is_file():
|
||||
continue
|
||||
if entry.endswith(".policy"):
|
||||
dest = tmp / (entry[: -len(".policy")] + ".json")
|
||||
shutil.copy2(src_file, dest)
|
||||
any_policy = True
|
||||
elif entry.endswith((".json", ".yaml", ".yml")):
|
||||
shutil.copy2(src_file, tmp / entry)
|
||||
any_policy = True
|
||||
return tmp, any_policy
|
||||
|
||||
|
||||
def _policy_sha() -> str:
|
||||
"""Return the git SHA of the policy file (D-231).
|
||||
|
||||
Uses ``git rev-parse HEAD:platform/abac/token-vend.policy`` so the
|
||||
SHA is stable across checkouts (blob SHA, not commit SHA). Falls
|
||||
back to ``"unknown"`` when git is unavailable or the file is not
|
||||
tracked (e.g. during local development before the first commit).
|
||||
"""
|
||||
repo_root = os.environ.get("NOVA_REPO_ROOT") or os.getcwd()
|
||||
try:
|
||||
sha = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD:platform/abac/token-vend.policy"],
|
||||
cwd=repo_root,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=5,
|
||||
).strip()
|
||||
return sha or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def evaluate_token_vend_policy(
|
||||
payload: dict,
|
||||
) -> Tuple[bool, list, str]:
|
||||
"""Evaluate the token-vend ABAC policy against ``payload``.
|
||||
|
||||
Args:
|
||||
payload: the ABAC authorization payload (see module docstring).
|
||||
|
||||
Returns:
|
||||
``(allowed, pcrs, policy_sha)`` where ``allowed`` is ``True``
|
||||
iff no PCR has ``result == "fail"`` with ``severity ==
|
||||
"critical"`` (C-6.1). ``pcrs`` is the raw list of
|
||||
``PolicyCheckResult`` dicts from the engine. ``policy_sha`` is
|
||||
the git blob SHA of the policy file (D-231).
|
||||
|
||||
Raises:
|
||||
Exception: any engine error propagates — the caller MUST catch
|
||||
and fail closed (403 ``abac_eval_failed``). This function
|
||||
does NOT swallow errors: failing closed is the *caller's*
|
||||
responsibility so the denial audit event is emitted at the
|
||||
Lambda boundary with the right reason code.
|
||||
"""
|
||||
engine = get_engine()
|
||||
# Nova ABAC policies use the `.policy` extension (REQ-339), but
|
||||
# KyvernoJsonEngine only loads `.json`/`.yaml`/`.yml`. Materialize a
|
||||
# temp dir with `.policy` → `.json` twins so the engine picks them
|
||||
# up. The temp dir is removed in the `finally` block.
|
||||
pol_dir, _ = _materialize_policy_dir(_POLICY_DIR)
|
||||
try:
|
||||
pcrs = engine.evaluate(payload, pol_dir, _CONTRACT_ID)
|
||||
finally:
|
||||
shutil.rmtree(pol_dir, ignore_errors=True)
|
||||
allowed = not any(
|
||||
p.get("result") == "fail" and str(p.get("severity", "")).lower() == "critical"
|
||||
for p in pcrs
|
||||
)
|
||||
return allowed, pcrs, _policy_sha()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
with open(sys.argv[1]) as fh:
|
||||
pl = json.load(fh)
|
||||
else:
|
||||
pl = json.loads(sys.stdin.read())
|
||||
allowed, pcrs, sha = evaluate_token_vend_policy(pl)
|
||||
print(json.dumps({"allowed": allowed, "policy_sha": sha, "pcrs": pcrs}, indent=2))
|
||||
+14
-13
@@ -169,20 +169,21 @@ def check(env: str, evidence: dict) -> Tuple[bool, str]:
|
||||
return (True, f"{env}: all {len(concerns)} concern(s) pass")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def cli_main(argv) -> int:
|
||||
"""Thin CLI entry (P1): nova attestation-matrix <env> [evidence.json]."""
|
||||
import json
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: attestation_matrix.py <env> [evidence.json]", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
_env = sys.argv[1]
|
||||
if len(argv) < 2:
|
||||
print("usage: attestation_matrix <env> [evidence.json]", file=sys.stderr)
|
||||
return 2
|
||||
_env = argv[1]
|
||||
_evidence = {}
|
||||
if len(sys.argv) >= 3 and os.path.isfile(sys.argv[2]):
|
||||
with open(sys.argv[2]) as f:
|
||||
if len(argv) >= 3 and os.path.isfile(argv[2]):
|
||||
with open(argv[2]) as f:
|
||||
_evidence = json.load(f)
|
||||
ok, reason = check(_env, _evidence)
|
||||
if ok:
|
||||
print(f"ATTESTATION PASS: {reason}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"ATTESTATION BLOCK: {reason}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"ATTESTATION PASS: {reason}") if ok else print(f"ATTESTATION BLOCK: {reason}", file=sys.stderr)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(cli_main(sys.argv))
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Nova credential store — ``~/.nova/credentials.json`` (C-7.3, REQ-344).
|
||||
|
||||
Stores the OIDC token + PAT metadata (jti, exp, type) ONLY — **NOT the
|
||||
raw PAT** (C-7.3). The file is 0600. "Most recent wins" (D-226 Q5):
|
||||
``active_credential_jti`` points at the most-recently-stored credential.
|
||||
|
||||
Shape::
|
||||
|
||||
{
|
||||
"active_credential_jti": "<jti>",
|
||||
"credentials": [
|
||||
{"jti": ..., "type": "developer_pat"|"nova_oidc_token",
|
||||
"exp": <epoch>, "token": "<oidc jwt>", "stored_at": <epoch>}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def credentials_path() -> Path:
|
||||
return Path(os.environ.get("NOVA_CREDENTIALS_FILE")
|
||||
or os.path.expanduser("~/.nova/credentials.json"))
|
||||
|
||||
|
||||
def _emit_audit(event_type: str, **fields) -> None:
|
||||
payload = {"event": event_type, **fields}
|
||||
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def store_credential(
|
||||
jti: str,
|
||||
cred_type: str,
|
||||
exp: int,
|
||||
oidc_token: str,
|
||||
path: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Store an OIDC token + PAT metadata (NOT the raw PAT, C-7.3). 0600."""
|
||||
p = path or credentials_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {"active_credential_jti": jti, "credentials": []}
|
||||
if p.exists():
|
||||
try:
|
||||
data = json.loads(p.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {"active_credential_jti": jti, "credentials": []}
|
||||
creds = data.get("credentials", []) or []
|
||||
# Replace any existing entry with the same jti.
|
||||
creds = [c for c in creds if c.get("jti") != jti]
|
||||
import time
|
||||
creds.append({
|
||||
"jti": jti, "type": cred_type, "exp": exp,
|
||||
"token": oidc_token, "stored_at": int(time.time()),
|
||||
})
|
||||
data["credentials"] = creds
|
||||
data["active_credential_jti"] = jti
|
||||
p.write_text(json.dumps(data, indent=2, sort_keys=True))
|
||||
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
||||
_emit_audit("auth.login", jti=jti, type=cred_type)
|
||||
|
||||
|
||||
def load_credentials(path: Optional[Path] = None) -> dict:
|
||||
"""Load the credentials file (or ``{}`` if absent)."""
|
||||
p = path or credentials_path()
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def active_credential(path: Optional[Path] = None) -> Optional[dict]:
|
||||
"""Return the active credential dict (or ``None``)."""
|
||||
data = load_credentials(path)
|
||||
active_jti = data.get("active_credential_jti")
|
||||
for c in data.get("credentials", []) or []:
|
||||
if c.get("jti") == active_jti:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def emit_status_audit(path: Optional[Path] = None) -> dict:
|
||||
"""Emit ``auth.status`` audit + return the credentials data."""
|
||||
data = load_credentials(path)
|
||||
_emit_audit("auth.status", active_jti=data.get("active_credential_jti"))
|
||||
return data
|
||||
|
||||
|
||||
def emit_revoke_audit(jti: str) -> None:
|
||||
_emit_audit("auth.revoke", jti=jti)
|
||||
@@ -218,12 +218,18 @@ def compute(contract_id: str, environment: str,
|
||||
return signal
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("usage: confidence_signal.py <inputs.json> <environment>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
env = sys.argv[2]
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||
def cli_main(argv) -> int:
|
||||
"""Thin CLI entry (P1): nova confidence <inputs.json> <environment>."""
|
||||
if len(argv) < 3:
|
||||
print("usage: confidence <inputs.json> <environment>", file=sys.stderr)
|
||||
return 2
|
||||
env = argv[2]
|
||||
with open(argv[1], "r", encoding="utf-8") as fh:
|
||||
inputs = json.load(fh)
|
||||
sig = compute("cli", env, inputs)
|
||||
print(json.dumps(asdict(sig), indent=2))
|
||||
print(json.dumps(asdict(sig), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(cli_main(sys.argv))
|
||||
+98
-4
@@ -1,4 +1,4 @@
|
||||
"""Environment helper (D-108, REQ-159, REQ-164).
|
||||
"""Environment helper (D-108, REQ-159, REQ-164, REQ-330).
|
||||
|
||||
During the Nova rebrand transition window (P2–P4), `get_env` read
|
||||
`NOVA_*` preferred with the legacy `ACDL_*` name as the fallback. **P5
|
||||
@@ -9,14 +9,27 @@ During the Nova rebrand transition window (P2–P4), `get_env` read
|
||||
`.env.secrets` shell export in `scripts/run_platform.sh` and the Python
|
||||
parser in `core/regression_verify.py`) were updated to NOVA-only in P5
|
||||
(the G-106 dual-read contract was retired with the fallback).
|
||||
|
||||
P2 (REQ-330): `synthesize_local_env(contract_path, environment)` produces
|
||||
a purely synthetic local env dict (account_id placeholder, region
|
||||
"local", no real AWS resources) from a contract YAML. Mirrors the shape
|
||||
of core/environments/*.json (validates against
|
||||
schemas/environment.schema.json) so `nova apply --local` can run the
|
||||
contract resolver + Terraform adapter without provisioning cloud
|
||||
resources. This is the local-tier counterpart of
|
||||
core/onboarding.py:generate_env_file() (the request-path binding
|
||||
generator).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
__all__ = ["get_env"]
|
||||
import yaml
|
||||
|
||||
__all__ = ["get_env", "synthesize_local_env"]
|
||||
|
||||
|
||||
def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
@@ -28,4 +41,85 @@ def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
val = os.environ.get(f"NOVA_{name}")
|
||||
if val:
|
||||
return val
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
# Default confidence thresholds per environment name (mirrors the schema
|
||||
# description: dev 0.50, qa 0.75, prod 0.90, dr 0.95). Used by
|
||||
# synthesize_local_env so the synthetic env matches the real env semantics.
|
||||
_DEFAULT_THRESHOLDS: Dict[str, float] = {
|
||||
"dev": 0.50,
|
||||
"qa": 0.75,
|
||||
"prod": 0.90,
|
||||
"dr": 0.95,
|
||||
}
|
||||
|
||||
|
||||
def synthesize_local_env(
|
||||
contract_path: str,
|
||||
environment: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Synthesize a local env dict from a contract YAML (REQ-330).
|
||||
|
||||
Reads the contract YAML (``yaml.safe_load``), derives a placeholder
|
||||
environment binding that ``nova apply --local`` can use WITHOUT
|
||||
provisioning real AWS resources. The produced dict:
|
||||
|
||||
- ``name`` — the environment name (from the arg or the contract's
|
||||
``environment`` field, defaulting to ``"dev"``).
|
||||
- ``account_id`` — ``"000000000000"`` (the schema-allowed placeholder
|
||||
for an unbound environment; real account id filled by the platform).
|
||||
- ``region`` — ``"local"`` (the local-tier sentinel; never a real
|
||||
AWS region).
|
||||
- ``state_backend`` — ``{bucket: "local-tfstate", lock_table:
|
||||
"local-locks"}`` (local state; LocalS3StateBackend rewrites the
|
||||
terraform backend to ``backend "local"`` using the stack name as
|
||||
the state path, so no S3 bucket is used).
|
||||
- ``network`` — a local RFC1918 CIDR + a single fake AZ.
|
||||
- ``runner_role_arn`` — a placeholder ARN for the local tier.
|
||||
- ``autonomy`` — ``"full"`` (the local tier is autonomous).
|
||||
- ``confidence_threshold`` — the per-env default (0.50 for dev).
|
||||
|
||||
The dict mirrors the shape of ``core/environments/*.json`` and
|
||||
validates against ``schemas/environment.schema.json``. No cloud
|
||||
provisioning occurs — purely synthetic.
|
||||
|
||||
Args:
|
||||
contract_path: Path to the contract YAML file.
|
||||
environment: Optional environment name override (defaults to the
|
||||
contract's ``environment`` field, or ``"dev"``).
|
||||
|
||||
Returns:
|
||||
The synthetic local env dict.
|
||||
"""
|
||||
contract_path_obj = Path(contract_path)
|
||||
contract: Dict[str, Any] = {}
|
||||
if contract_path_obj.is_file():
|
||||
with open(contract_path_obj) as fh:
|
||||
contract = yaml.safe_load(fh) or {}
|
||||
|
||||
env_name = environment or contract.get("environment", "dev")
|
||||
stack_name = contract.get("id", env_name)
|
||||
threshold = _DEFAULT_THRESHOLDS.get(env_name, 0.50)
|
||||
|
||||
return {
|
||||
"name": env_name,
|
||||
"description": (
|
||||
f"Synthetic local-tier environment for contract '{stack_name}' "
|
||||
f"(environment={env_name}). No real AWS resources — generated "
|
||||
f"by core.env.synthesize_local_env (REQ-330) for nova apply --local."
|
||||
),
|
||||
"account_id": "000000000000",
|
||||
"region": "local",
|
||||
"state_backend": {
|
||||
"bucket": "local-tfstate",
|
||||
"lock_table": "local-locks",
|
||||
},
|
||||
"network": {
|
||||
"vpc_cidr": "10.250.0.0/16",
|
||||
"azs": ["local-a"],
|
||||
},
|
||||
"runner_role_arn": "arn:aws:iam::000000000000:role/local-runner",
|
||||
"autonomy": "full",
|
||||
"confidence_threshold": threshold,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Nova init scaffolding logic (P1, REQ-325).
|
||||
|
||||
Creates .nova/ directory structure + secrets-exclusion .gitignore lines
|
||||
in the current working directory. nova/init.py delegates here so the
|
||||
subcommand stays thin (≤50 lines, ≤3 functions).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
SECRETS_IGNORE_LINES = (
|
||||
"~/.nova/credentials.json",
|
||||
".nova/credentials.json",
|
||||
"*.pem",
|
||||
"*.key",
|
||||
".env",
|
||||
".env.*",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_gitignore(root: Path, force: bool) -> None:
|
||||
gi = root / ".gitignore"
|
||||
existing = gi.read_text().splitlines() if gi.is_file() else []
|
||||
additions = [ln for ln in SECRETS_IGNORE_LINES if ln not in existing]
|
||||
if not additions:
|
||||
return
|
||||
blob = gi.read_text() if gi.is_file() else ""
|
||||
if blob and not blob.endswith("\n"):
|
||||
blob += "\n"
|
||||
blob += "\n".join(additions) + "\n"
|
||||
gi.write_text(blob)
|
||||
|
||||
|
||||
def scaffold(root: Path | None = None, force: bool = False) -> int:
|
||||
"""Create .nova/ + .nova/contract.yml.attestations/ + .gitignore lines."""
|
||||
root = root or Path.cwd()
|
||||
nova_dir = root / ".nova"
|
||||
attest_dir = nova_dir / "contract.yml.attestations"
|
||||
if nova_dir.exists() and not force:
|
||||
print(f"refusing: {nova_dir} already exists (use --force to overwrite)")
|
||||
return 1
|
||||
nova_dir.mkdir(parents=True, exist_ok=True)
|
||||
attest_dir.mkdir(parents=True, exist_ok=True)
|
||||
_ensure_gitignore(root, force)
|
||||
print(f"scaffolded: {nova_dir} (+ {attest_dir.name}/, .gitignore secrets)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(scaffold())
|
||||
@@ -0,0 +1,213 @@
|
||||
"""JWS-from-PAT key derivation + symmetric attestation (REQ-332, C-5.2).
|
||||
|
||||
C-5.2 grill fix: the "public key derivable from the PAT" acceptance
|
||||
criterion is re-interpreted as a SYMMETRIC scheme. The PAT (Personal
|
||||
Access Token) is the shared secret; the JWS signing key AND the
|
||||
verification key are both derived from the PAT via the same HKDF-SHA256
|
||||
KDF. The JWS uses HMAC-SHA256 (HS256) — a symmetric MAC, not an
|
||||
asymmetric signature.
|
||||
|
||||
Key derivation (NIST SP 800-56C / RFC 5869):
|
||||
key = HKDF-SHA256(
|
||||
input_key_material = PAT.encode(),
|
||||
salt = b"nova-local-attestation",
|
||||
info = b"jws-signing-key",
|
||||
length = 32,
|
||||
)
|
||||
|
||||
The resulting 32-byte key is used both to sign (sign_attestation) and to
|
||||
verify (verify_attestation). Anyone holding the PAT can derive the same
|
||||
key and verify the attestation; without the PAT, the HMAC cannot be
|
||||
forged. This satisfies INV-14..17:
|
||||
|
||||
- INV-14: the signing key is derived from the PAT (no separate key
|
||||
material; no long-lived private key on disk).
|
||||
- INV-15: the key never leaves the derivation (it is recomputed from
|
||||
the PAT on each sign/verify call; not cached, not persisted).
|
||||
- INV-16: the salt + info are fixed constants binding the key to the
|
||||
"nova-local-attestation / jws-signing-key" purpose (key separation).
|
||||
- INV-17: tamper detection via the HMAC verification (verify_attestation
|
||||
raises on any signature mismatch).
|
||||
|
||||
The JWS is the compact serialization:
|
||||
b64url(header).b64url(payload).b64url(signature)
|
||||
where header = {"alg":"HS256","typ":"JWT"}, payload = the JWT claims
|
||||
(the attestation payload dict), and signature = HMAC-SHA256(key,
|
||||
b64url(header) + "." + b64url(payload)).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
__all__ = [
|
||||
"derive_signing_key",
|
||||
"sign_attestation",
|
||||
"verify_attestation",
|
||||
"JWSValidationError",
|
||||
]
|
||||
|
||||
# Fixed KDF parameters (INV-16: key separation — binds the derived key to
|
||||
# the nova-local-attestation / jws-signing-key purpose).
|
||||
_KDF_SALT = b"nova-local-attestation"
|
||||
_KDF_INFO = b"jws-signing-key"
|
||||
_KDF_LENGTH = 32 # 256-bit key for HMAC-SHA256
|
||||
|
||||
# JWS header for HS256 (symmetric HMAC-SHA256).
|
||||
_JWS_HEADER = {"alg": "HS256", "typ": "JWT"}
|
||||
|
||||
|
||||
class JWSValidationError(Exception):
|
||||
"""Raised when a JWS attestation fails verification (signature mismatch,
|
||||
malformed token, or wrong PAT)."""
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
"""RFC 7515 base64url encoding WITHOUT padding (JWS compact form)."""
|
||||
import base64
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(segment: str) -> bytes:
|
||||
"""RFC 7515 base64url decoding (re-adds stripped padding)."""
|
||||
import base64
|
||||
pad = "=" * (-len(segment) % 4)
|
||||
return base64.urlsafe_b64decode(segment + pad)
|
||||
|
||||
|
||||
def _hkdf_sha256(input_key_material: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
||||
"""HKDF-SHA256 (RFC 5869).
|
||||
|
||||
Prefers cryptography.hazmat.primitives.kdf.hkdf.HKDF (the cryptography
|
||||
extra); falls back to a hashlib-based implementation if cryptography
|
||||
is unavailable (so the module works in a minimal Lambda runtime).
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=length,
|
||||
salt=salt,
|
||||
info=info,
|
||||
)
|
||||
return hkdf.derive(input_key_material)
|
||||
except ImportError: # pragma: no cover - fallback path
|
||||
return _hkdf_sha256_hashlib(input_key_material, salt, info, length)
|
||||
|
||||
|
||||
def _hkdf_sha256_hashlib(input_key_material: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
||||
"""RFC 5869 HKDF-SHA256 using only hashlib + hmac (fallback)."""
|
||||
# Extract: PRK = HMAC-SHA256(salt, IKM)
|
||||
prk = hmac.new(salt, input_key_material, hashlib.sha256).digest()
|
||||
# Expand: T(i) = HMAC-SHA256(PRK, T(i-1) | info | i)
|
||||
okm = b""
|
||||
t = b""
|
||||
block = 0
|
||||
while len(okm) < length:
|
||||
block += 1
|
||||
t = hmac.new(prk, t + info + bytes([block]), hashlib.sha256).digest()
|
||||
okm += t
|
||||
return okm[:length]
|
||||
|
||||
|
||||
def derive_signing_key(pat: str) -> bytes:
|
||||
"""Derive the 32-byte symmetric JWS signing key from a PAT.
|
||||
|
||||
HKDF-SHA256(PAT.encode(), salt=b'nova-local-attestation',
|
||||
info=b'jws-signing-key', length=32).
|
||||
|
||||
The same PAT always yields the same key (deterministic); the key is
|
||||
never cached or persisted (INV-15 — recomputed on each call).
|
||||
"""
|
||||
if not isinstance(pat, str) or not pat:
|
||||
raise ValueError("pat must be a non-empty string")
|
||||
return _hkdf_sha256(
|
||||
input_key_material=pat.encode("utf-8"),
|
||||
salt=_KDF_SALT,
|
||||
info=_KDF_INFO,
|
||||
length=_KDF_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
def sign_attestation(payload: Dict[str, Any], pat: str) -> str:
|
||||
"""Produce a compact JWS (HS256) for the attestation payload.
|
||||
|
||||
Args:
|
||||
payload: the JWT claims (the attestation payload dict).
|
||||
pat: the Personal Access Token (shared secret).
|
||||
|
||||
Returns:
|
||||
The compact JWS string: b64url(header).b64url(payload).b64url(signature).
|
||||
The header is {"alg":"HS256","typ":"JWT"}; the payload is the
|
||||
JSON-encoded claims; the signature is HMAC-SHA256(key, header.payload).
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload must be a dict")
|
||||
key = derive_signing_key(pat)
|
||||
header_segment = _b64url_encode(
|
||||
json.dumps(_JWS_HEADER, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
)
|
||||
payload_segment = _b64url_encode(
|
||||
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
)
|
||||
signing_input = f"{header_segment}.{payload_segment}".encode("ascii")
|
||||
signature = hmac.new(key, signing_input, hashlib.sha256).digest()
|
||||
signature_segment = _b64url_encode(signature)
|
||||
return f"{header_segment}.{payload_segment}.{signature_segment}"
|
||||
|
||||
|
||||
def verify_attestation(jws: str, pat: str) -> Dict[str, Any]:
|
||||
"""Verify a compact JWS (HS256) attestation and return the payload.
|
||||
|
||||
Derives the same key from the PAT, recomputes the HMAC, and compares
|
||||
in constant time. Raises JWSValidationError on:
|
||||
- malformed JWS (not 3 segments, bad base64, bad JSON)
|
||||
- signature mismatch (tampering or wrong PAT)
|
||||
- wrong header (alg != HS256)
|
||||
|
||||
Args:
|
||||
jws: the compact JWS string from sign_attestation.
|
||||
pat: the Personal Access Token (shared secret).
|
||||
|
||||
Returns:
|
||||
The decoded payload dict (the JWT claims) on success.
|
||||
"""
|
||||
if not isinstance(jws, str) or not jws:
|
||||
raise JWSValidationError("jws must be a non-empty string")
|
||||
parts = jws.split(".")
|
||||
if len(parts) != 3:
|
||||
raise JWSValidationError(f"malformed JWS: expected 3 segments, got {len(parts)}")
|
||||
header_segment, payload_segment, signature_segment = parts
|
||||
|
||||
# Decode + validate the header.
|
||||
try:
|
||||
header = json.loads(_b64url_decode(header_segment))
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
raise JWSValidationError(f"malformed JWS header: {e}") from e
|
||||
if not isinstance(header, dict) or header.get("alg") != "HS256":
|
||||
raise JWSValidationError(
|
||||
f"unsupported JWS alg: expected HS256, got {header.get('alg')!r}"
|
||||
)
|
||||
|
||||
# Recompute the signature with the key derived from the PAT.
|
||||
key = derive_signing_key(pat)
|
||||
signing_input = f"{header_segment}.{payload_segment}".encode("ascii")
|
||||
expected_signature = hmac.new(key, signing_input, hashlib.sha256).digest()
|
||||
actual_signature = _b64url_decode(signature_segment)
|
||||
if not hmac.compare_digest(expected_signature, actual_signature):
|
||||
raise JWSValidationError(
|
||||
"JWS signature verification failed (tampered token or wrong PAT)"
|
||||
)
|
||||
|
||||
# Decode + return the payload.
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(payload_segment))
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
raise JWSValidationError(f"malformed JWS payload: {e}") from e
|
||||
if not isinstance(payload, dict):
|
||||
raise JWSValidationError("JWS payload is not a JSON object")
|
||||
return payload
|
||||
@@ -0,0 +1,151 @@
|
||||
"""KMS-signed JWT issuance for the Nova IdP (REQ-337, REQ-336).
|
||||
|
||||
Signs OIDC tokens with an AWS KMS asymmetric key (``ECC_NIST_P256``,
|
||||
``ECDSA_SHA_256`` → JWS ``ES256``) and exposes the public key as a JWK
|
||||
for the JWKS endpoint (REQ-338).
|
||||
|
||||
## DER → raw ECDSA conversion (the #1 gotcha, RESEARCH §5)
|
||||
|
||||
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. :func:`der_to_raw_ecdsa` performs the
|
||||
conversion via ``cryptography``'s ``decode_dss_signature``. This is the
|
||||
core of REQ-337 and is verified by the CAP-037 round-trip test.
|
||||
|
||||
## Lazy boto3
|
||||
|
||||
``boto3.client("kms")`` is constructed lazily so the module imports
|
||||
without AWS creds (mirrors ``nova_idp_auth.py``). Tests inject a mock
|
||||
client via :func:`set_kms_client_for_testing`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import boto3
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
EllipticCurvePublicKey,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
|
||||
# Default KMS key alias for Nova OIDC signing (REQ-337).
|
||||
DEFAULT_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||
|
||||
_kms_client = None
|
||||
|
||||
|
||||
def _get_kms_client():
|
||||
"""Lazy boto3 KMS client singleton (mirrors nova_idp_auth.py)."""
|
||||
global _kms_client
|
||||
if _kms_client is None:
|
||||
_kms_client = boto3.client("kms")
|
||||
return _kms_client
|
||||
|
||||
|
||||
def set_kms_client_for_testing(client: Any) -> None:
|
||||
"""Inject a mock KMS client for tests (no real AWS calls)."""
|
||||
global _kms_client
|
||||
_kms_client = client
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
"""Base64url encode without padding (RFC 7515 §2)."""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def der_to_raw_ecdsa(der_sig: bytes, coord_len: int = 32) -> bytes:
|
||||
"""Convert a DER-encoded ECDSA signature to raw ``r‖s`` (JWS format).
|
||||
|
||||
KMS returns DER; JWS requires raw ``r‖s`` concatenation, each
|
||||
coordinate ``coord_len`` bytes big-endian (32 for P-256, 48 for
|
||||
P-384). Uses ``cryptography``'s ``decode_dss_signature`` to parse
|
||||
the DER, then zero-pads each integer to ``coord_len``.
|
||||
|
||||
Raises:
|
||||
ValueError: if a coordinate does not fit in ``coord_len`` bytes
|
||||
(the integer is larger than the curve allows — indicates a
|
||||
malformed signature or wrong ``coord_len``).
|
||||
"""
|
||||
r, s = decode_dss_signature(der_sig)
|
||||
if r.bit_length() > coord_len * 8 or s.bit_length() > coord_len * 8:
|
||||
raise ValueError(
|
||||
f"ECDSA coordinate does not fit in {coord_len} bytes "
|
||||
f"(r={r.bit_length()} bits, s={s.bit_length()} bits)"
|
||||
)
|
||||
return r.to_bytes(coord_len, "big") + s.to_bytes(coord_len, "big")
|
||||
|
||||
|
||||
def sign_jwt(claims: dict, key_id: str = DEFAULT_KEY_ID) -> str:
|
||||
"""Build + sign a JWT with KMS (REQ-337, REQ-336).
|
||||
|
||||
Args:
|
||||
claims: the JWT claims payload (``sub, aud, iss, exp, iat, jti,
|
||||
roles`` per REQ-336, plus ``typ`` for PATs).
|
||||
key_id: the KMS key ID or alias (default
|
||||
``alias/nova-oidc-signing``).
|
||||
|
||||
Returns:
|
||||
The compact JWS (``header.payload.signature``), ``ES256``,
|
||||
with the signature in raw ``r‖s`` form (DER→raw converted).
|
||||
"""
|
||||
header = {"alg": "ES256", "typ": "JWT", "kid": key_id}
|
||||
signing_input = (
|
||||
_b64url(json.dumps(header, separators=(",", ":"), sort_keys=True).encode())
|
||||
+ "."
|
||||
+ _b64url(json.dumps(claims, separators=(",", ":"), sort_keys=True).encode())
|
||||
)
|
||||
resp = _get_kms_client().sign(
|
||||
KeyId=key_id,
|
||||
Message=signing_input.encode("ascii"),
|
||||
MessageType="RAW",
|
||||
SigningAlgorithm="ECDSA_SHA_256",
|
||||
)
|
||||
der_sig = resp["Signature"]
|
||||
raw_sig = der_to_raw_ecdsa(der_sig)
|
||||
return signing_input + "." + _b64url(raw_sig)
|
||||
|
||||
|
||||
def get_jwk(key_id: str = DEFAULT_KEY_ID) -> dict:
|
||||
"""Fetch the KMS public key and return it as a JWK (REQ-338).
|
||||
|
||||
Calls ``kms.get_public_key`` → DER SPKI → ``cryptography``'s
|
||||
``load_der_public_key`` → JWK ``{"kty":"EC","crv":"P-256","kid":...,
|
||||
"x":...,"y":...}``. The ``x``/``y`` are base64url-encoded
|
||||
big-endian 32-byte coordinates.
|
||||
"""
|
||||
resp = _get_kms_client().get_public_key(KeyId=key_id)
|
||||
pub = load_der_public_key(resp["PublicKey"])
|
||||
if not isinstance(pub, EllipticCurvePublicKey):
|
||||
raise ValueError(
|
||||
f"KMS public key is not an EC key (got {type(pub).__name__})"
|
||||
)
|
||||
nums = pub.public_numbers()
|
||||
# P-256 coordinates are 32 bytes big-endian.
|
||||
x = nums.x.to_bytes(32, "big")
|
||||
y = nums.y.to_bytes(32, "big")
|
||||
return {
|
||||
"kty": "EC",
|
||||
"crv": "P-256",
|
||||
"kid": key_id,
|
||||
"x": _b64url(x),
|
||||
"y": _b64url(y),
|
||||
"alg": "ES256",
|
||||
"use": "sig",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
import sys
|
||||
|
||||
if "--print-jwk" in sys.argv:
|
||||
print(json.dumps(get_jwk(), indent=2))
|
||||
else:
|
||||
print("usage: python3 -m core.kms_signing --print-jwks", file=sys.stderr)
|
||||
@@ -457,65 +457,149 @@ def _onboard_consumer(payload):
|
||||
}
|
||||
|
||||
|
||||
def dispatch_action(payload, event=None):
|
||||
"""Shared business-logic dispatch for the contract ingestor (REQ-329).
|
||||
|
||||
Both the AWS Lambda handler (``lambda_handler``) and the CLI path
|
||||
(``cli_main`` / ``__main__``) call this function so the two paths share
|
||||
a single source of truth for action routing, contract validation, the
|
||||
DynamoDB write, and error reporting (NFR-7 — dual-use, single source).
|
||||
|
||||
Args:
|
||||
payload: the decoded action envelope dict
|
||||
``{ consumerRepo, contractId, contract, environment, action }``.
|
||||
event: the raw Lambda Function-URL event (used for IAM caller
|
||||
identity validation). When ``None`` (the CLI path), the identity
|
||||
check uses the ``NOVA_LAMBDA_LOCAL_BYPASS`` env var — CLI invocations
|
||||
are local-only and do not carry an IAM principal.
|
||||
|
||||
Returns:
|
||||
The action result dict (e.g. ``{status, contractId, action, ...}``)
|
||||
on success. Raises ``ValueError`` for validation failures and other
|
||||
exceptions for downstream errors — the caller is responsible for
|
||||
mapping these to the appropriate status code / exit code.
|
||||
"""
|
||||
action = payload.get("action", "submit_contract")
|
||||
# Validate caller identity against the payload (P1-2). The CLI path
|
||||
# passes event=None; the fail-closed check honours the local bypass.
|
||||
_validate_caller_identity(event or {}, payload)
|
||||
if action == "submit_contract":
|
||||
# Validate required fields up front for a clean 400.
|
||||
for field in ("consumerRepo", "contractId", "contract", "environment"):
|
||||
if field not in payload:
|
||||
raise ValueError(f"missing field: {field}")
|
||||
result = _submit_contract(payload)
|
||||
elif action == "report_error":
|
||||
result = _report_error(payload)
|
||||
elif action == "validate_change_request":
|
||||
result = _validate_change_request(payload)
|
||||
elif action == "onboard_consumer":
|
||||
result = _onboard_consumer(payload)
|
||||
else:
|
||||
raise ValueError(f"unknown action: {action}")
|
||||
return result
|
||||
|
||||
|
||||
def _to_http_response(result_or_error):
|
||||
"""Map a dispatch_action result / exception to a Lambda HTTP response.
|
||||
|
||||
Shared error→status mapping so both Lambda + CLI paths interpret errors
|
||||
identically (REQ-329 dual-use).
|
||||
"""
|
||||
if isinstance(result_or_error, Exception):
|
||||
msg = str(result_or_error)
|
||||
if isinstance(result_or_error, ValueError):
|
||||
if "missing IAM caller identity" in msg:
|
||||
return {"statusCode": 401, "body": json.dumps({"error": msg})}
|
||||
return {"statusCode": 400, "body": json.dumps({"error": msg})}
|
||||
return {"statusCode": 500, "body": json.dumps({"error": msg})}
|
||||
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler entry point.
|
||||
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use).
|
||||
|
||||
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
||||
containing ``{ consumerRepo, contractId, contract, environment, action }``.
|
||||
Parses the Lambda-specific envelope then delegates to the shared
|
||||
``dispatch_action`` business logic.
|
||||
"""
|
||||
try:
|
||||
body = event.get("body", "{}")
|
||||
if isinstance(body, str):
|
||||
payload = json.loads(body)
|
||||
else:
|
||||
payload = body
|
||||
action = payload.get("action", "submit_contract")
|
||||
# Validate caller identity against the payload (P1-2).
|
||||
_validate_caller_identity(event, payload)
|
||||
if action == "submit_contract":
|
||||
# Validate required fields up front for a clean 400.
|
||||
for field in ("consumerRepo", "contractId", "contract", "environment"):
|
||||
if field not in payload:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"body": json.dumps({"error": f"missing field: {field}"}),
|
||||
}
|
||||
result = _submit_contract(payload)
|
||||
elif action == "report_error":
|
||||
result = _report_error(payload)
|
||||
elif action == "validate_change_request":
|
||||
result = _validate_change_request(payload)
|
||||
elif action == "onboard_consumer":
|
||||
result = _onboard_consumer(payload)
|
||||
else:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"body": json.dumps({"error": f"unknown action: {action}"}),
|
||||
}
|
||||
return {"statusCode": 200, "body": json.dumps(result)}
|
||||
except ValueError as e:
|
||||
# P10 (REQ-174): identity failures are 401, field validation is 400.
|
||||
if "missing IAM caller identity" in str(e):
|
||||
return {"statusCode": 401, "body": json.dumps({"error": str(e)})}
|
||||
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
|
||||
payload = json.loads(body) if isinstance(body, str) else body
|
||||
result = dispatch_action(payload, event=event)
|
||||
return _to_http_response(result)
|
||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
|
||||
return _to_http_response(e)
|
||||
|
||||
|
||||
# --- CLI: --check-readiness (D-133, REQ-218) ---------------------------
|
||||
# Invoked as: python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>
|
||||
# Delegates to core.submission_readiness.check_readiness() and prints the
|
||||
# structured ReadinessResult. Exits 0 if ready, 1 if not.
|
||||
def cli_main(argv=None):
|
||||
"""CLI entry point for the contract ingestor (REQ-329 dual-use).
|
||||
|
||||
Usage:
|
||||
python3 -m core.lambda.contract_ingestor --dispatch <payload.json>
|
||||
python3 -m core.lambda.contract_ingestor --dispatch-stdin < <payload.json>
|
||||
|
||||
Parses the CLI-specific input (a JSON file path or stdin) then delegates
|
||||
to the shared ``dispatch_action`` business logic — the same path as the
|
||||
Lambda handler. Returns a process exit code (0 success, 1 validation
|
||||
error, 2 internal error).
|
||||
"""
|
||||
import sys
|
||||
raw = argv if argv is not None else sys.argv[1:]
|
||||
# The --dispatch flag consumes the next positional arg as a payload path;
|
||||
# --dispatch-stdin reads the payload from stdin.
|
||||
if "--dispatch-stdin" in raw:
|
||||
payload = json.loads(sys.stdin.read())
|
||||
elif "--dispatch" in raw:
|
||||
idx = raw.index("--dispatch")
|
||||
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||
if not path:
|
||||
print("Usage: --dispatch <payload.json>", file=sys.stderr)
|
||||
return 2
|
||||
with open(path) as fh:
|
||||
payload = json.loads(fh.read())
|
||||
else:
|
||||
print(
|
||||
"Usage: python3 -m core.lambda.contract_ingestor --dispatch <payload.json>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
try:
|
||||
result = dispatch_action(payload, event=None)
|
||||
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||
return 0
|
||||
except ValueError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1
|
||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||
sys.stderr.write(f"internal error: {e}\n")
|
||||
return 2
|
||||
|
||||
|
||||
# --- CLI: --check-readiness (D-133, REQ-218) + --dispatch (REQ-329) ----
|
||||
# Invoked as:
|
||||
# python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>
|
||||
# python3 -m core.lambda.contract_ingestor --dispatch <payload.json>
|
||||
# The --check-readiness path delegates to core.submission_readiness; the
|
||||
# --dispatch path is the dual-use CLI entry (REQ-329) that calls the same
|
||||
# dispatch_action() as the Lambda handler.
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||
import sys
|
||||
if "--check-readiness" in sys.argv:
|
||||
sys.path.insert(
|
||||
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
from core.submission_readiness import cli_main
|
||||
from core.submission_readiness import cli_main as _readiness_cli
|
||||
|
||||
# Strip the --check-readiness flag; pass the file path.
|
||||
rest = [a for a in sys.argv[1:] if a != "--check-readiness"]
|
||||
sys.exit(cli_main(["check-readiness"] + rest))
|
||||
sys.exit(_readiness_cli(["check-readiness"] + rest))
|
||||
elif "--dispatch" in sys.argv or "--dispatch-stdin" in sys.argv:
|
||||
sys.exit(cli_main())
|
||||
else:
|
||||
print("Usage: python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>")
|
||||
print(
|
||||
"Usage: python3 -m core.lambda.contract_ingestor "
|
||||
"--check-readiness <submission.json> | --dispatch <payload.json>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Nova IdP auth Lambda — sign-up / sign-in / session (REQ-333, REQ-334).
|
||||
|
||||
Invoked via a Function URL (IAM auth) by the Nova CLI and consumer
|
||||
pipelines. Mirrors the ``contract_ingestor.py`` pattern: lazy
|
||||
``boto3.resource`` DynamoDB singleton, env-var table names,
|
||||
``NOVA_LAMBDA_LOCAL_BYPASS`` for local testing, ``__main__`` CLI block
|
||||
for dual-use (REQ-329).
|
||||
|
||||
## Argon2id password hashing (REQ-334, D-228, C-7.2)
|
||||
|
||||
Passwords are hashed with Argon2id via ``argon2-cffi``:
|
||||
|
||||
PasswordHasher(time_cost=3, memory_cost=65536, parallelism=1)
|
||||
|
||||
These are the OWASP minimum parameters (t=3, m=65536 KiB, p=1).
|
||||
Lambda memory **MUST be ≥ 512 MB** (Argon2id memory_cost ~64 MiB +
|
||||
runtime overhead).
|
||||
|
||||
**D-228 (amended) — fail-closed:** there is no maintained pure-Python
|
||||
Argon2 implementation; a pure-Python crypto fallback is a liability
|
||||
(weaker hashing, violates INV-16's spirit). If the ``argon2`` C
|
||||
extension fails to import, the Lambda **fails closed** —
|
||||
``_ARGON2_AVAILABLE`` is set ``False`` at cold-start, and
|
||||
:func:`hash_password` / :func:`verify_password` raise
|
||||
``Argon2UnavailableError``. The handler catches this and returns
|
||||
**HTTP 503** (``{"error": "argon2_unavailable"}``) — **no pure-Python
|
||||
fallback, no weak hash, no crash.** This is verified by the explicit
|
||||
``test_argon2_fail_closed`` test (C-1.2).
|
||||
|
||||
## No raw passwords anywhere (INV-16)
|
||||
|
||||
Raw passwords are NEVER:
|
||||
* written to DynamoDB (only ``password_hash`` is stored),
|
||||
* logged (the handler never logs the password argument),
|
||||
* put in traces / env vars / X-Ray segments.
|
||||
|
||||
Audit events (``auth.sign_up``, ``auth.sign_in``,
|
||||
``auth.session_created``) are emitted to stderr as JSON; they carry the
|
||||
``user_id`` / ``email`` but **never** the password.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argon2id — fail-closed import (REQ-334, D-228, C-7.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# try-import the C extension. If it fails (missing abi3 wheel, wrong
|
||||
# glibc, etc.), _ARGON2_AVAILABLE becomes False and hash/verify raise
|
||||
# Argon2UnavailableError. The handler returns 503. NO pure-Python fallback.
|
||||
_ARGON2_AVAILABLE = False
|
||||
_PasswordHasher = None
|
||||
|
||||
try: # pragma: no cover - import success path covered by round-trip test
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_PasswordHasher = PasswordHasher
|
||||
_ARGON2_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover - exercised via mock in tests
|
||||
_ARGON2_AVAILABLE = False
|
||||
|
||||
# Define a stand-in so `verify_password` can raise the right type
|
||||
# even when argon2 isn't importable. VerifyMismatchError is only
|
||||
# raised by verify() which itself raises Argon2UnavailableError first.
|
||||
class VerifyMismatchError(Exception):
|
||||
"""Raised by verify_password when the password does not match."""
|
||||
|
||||
|
||||
class Argon2UnavailableError(Exception):
|
||||
"""Raised when the Argon2 C extension is unavailable (D-228 fail-closed).
|
||||
|
||||
The handler catches this and returns HTTP 503 — no pure-Python
|
||||
fallback, no weak hash.
|
||||
"""
|
||||
|
||||
|
||||
# OWASP-minimum Argon2id parameters (C-7.2):
|
||||
# time_cost=3, memory_cost=65536 KiB (64 MiB), parallelism=1
|
||||
_ARGON2_TIME_COST = 3
|
||||
_ARGON2_MEMORY_COST = 65536 # KiB
|
||||
_ARGON2_PARALLELISM = 1
|
||||
|
||||
|
||||
def _get_hasher():
|
||||
"""Return a PasswordHasher configured with the OWASP-min params.
|
||||
|
||||
Raises Argon2UnavailableError if the C extension is not loaded.
|
||||
"""
|
||||
if not _ARGON2_AVAILABLE or _PasswordHasher is None:
|
||||
raise Argon2UnavailableError(
|
||||
"argon2 C extension unavailable — refusing to hash with a "
|
||||
"weak fallback (D-228 fail-closed)"
|
||||
)
|
||||
return _PasswordHasher(
|
||||
time_cost=_ARGON2_TIME_COST,
|
||||
memory_cost=_ARGON2_MEMORY_COST,
|
||||
parallelism=_ARGON2_PARALLELISM,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password with Argon2id (OWASP-min params).
|
||||
|
||||
Returns the Argon2id hash string (includes the salt + params).
|
||||
|
||||
Raises:
|
||||
Argon2UnavailableError: if the ``argon2`` C extension is not
|
||||
importable (D-228 fail-closed — NO pure-Python fallback).
|
||||
"""
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError(
|
||||
"argon2 C extension unavailable — refusing to hash (D-228)"
|
||||
)
|
||||
# NOTE: the password argument is NEVER logged. Do not add debug
|
||||
# prints here that include `password`.
|
||||
return _get_hasher().hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hash_str: str) -> bool:
|
||||
"""Verify a password against an Argon2id hash.
|
||||
|
||||
Returns ``True`` if the password matches.
|
||||
|
||||
Raises:
|
||||
Argon2UnavailableError: if the ``argon2`` C extension is not
|
||||
importable.
|
||||
VerifyMismatchError: if the password does not match the hash.
|
||||
"""
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError(
|
||||
"argon2 C extension unavailable — refusing to verify (D-228)"
|
||||
)
|
||||
# argon2.PasswordHasher().verify raises VerifyMismatchError on
|
||||
# mismatch (and InvalidHash on a malformed hash). We let those
|
||||
# propagate; the handler maps them to 401 / 500.
|
||||
_get_hasher().verify(hash_str, password)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config (env-var table names, mirroring contract_ingestor.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
USERS_TABLE = os.environ.get("NOVA_USERS_TABLE", "nova-users")
|
||||
SESSIONS_TABLE = os.environ.get("NOVA_SESSIONS_TABLE", "nova-sessions")
|
||||
PASSWORD_RESETS_TABLE = os.environ.get(
|
||||
"NOVA_PASSWORD_RESETS_TABLE", "nova-password-resets"
|
||||
)
|
||||
# Session lifetime (seconds). Default 24h.
|
||||
SESSION_TTL_SECONDS = int(os.environ.get("NOVA_SESSION_TTL_SECONDS", "86400"))
|
||||
# Password-reset token lifetime (seconds). Default 15 min.
|
||||
RESET_TTL_SECONDS = int(os.environ.get("NOVA_RESET_TTL_SECONDS", "900"))
|
||||
|
||||
_dynamodb = None
|
||||
|
||||
|
||||
def _get_dynamodb():
|
||||
"""Lazy boto3 DynamoDB resource singleton (mirrors contract_ingestor)."""
|
||||
global _dynamodb
|
||||
if _dynamodb is None:
|
||||
_dynamodb = boto3.resource("dynamodb")
|
||||
return _dynamodb
|
||||
|
||||
|
||||
def _iso8601_now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
|
||||
|
||||
def _epoch_now() -> int:
|
||||
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
|
||||
|
||||
def _emit_audit(event_type: str, **fields) -> None:
|
||||
"""Emit an audit event to stderr as JSON (never includes passwords)."""
|
||||
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||
# Defense-in-depth: scrub any field literally named 'password' or
|
||||
# 'password_hash' value from the audit payload (they should never be
|
||||
# passed here, but a stray kwarg would leak — INV-16).
|
||||
for _k in ("password", "new_password", "old_password"):
|
||||
payload.pop(_k, None)
|
||||
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Business logic (sign_up / sign_in / create_session / reset flows)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require(fields, payload):
|
||||
"""Validate required fields; raise ValueError (→ 400) if missing."""
|
||||
for f in fields:
|
||||
if f not in payload or payload[f] in (None, ""):
|
||||
raise ValueError(f"missing field: {f}")
|
||||
|
||||
|
||||
def _lookup_user_by_email(email: str):
|
||||
"""Query nova-users GSI1 (email-index) → return the user item or None."""
|
||||
table = _get_dynamodb().Table(USERS_TABLE)
|
||||
resp = table.query(
|
||||
IndexName="email-index",
|
||||
KeyConditionExpression="email = :e",
|
||||
ExpressionAttributeValues={":e": email},
|
||||
Limit=1,
|
||||
)
|
||||
items = resp.get("Items", [])
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def sign_up(payload):
|
||||
"""Create a new user. Fails closed (503) if argon2 is unavailable.
|
||||
|
||||
Payload: { email, password, owner, roles }
|
||||
Writes to nova-users: PK user_id (uuid4), email, password_hash,
|
||||
owner, roles, created_at. The raw password is NEVER stored.
|
||||
"""
|
||||
_require(("email", "password", "owner", "roles"), payload)
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError("argon2 unavailable")
|
||||
email = payload["email"]
|
||||
password = payload["password"]
|
||||
owner = payload["owner"]
|
||||
roles = payload["roles"]
|
||||
if not isinstance(roles, list):
|
||||
raise ValueError("roles must be a list")
|
||||
|
||||
# Duplicate-email check → 409.
|
||||
if _lookup_user_by_email(email) is not None:
|
||||
raise _DuplicateEmailError(email)
|
||||
|
||||
user_id = str(uuid.uuid4())
|
||||
password_hash = hash_password(password) # fail-closed here
|
||||
created_at = _iso8601_now()
|
||||
item = {
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"password_hash": password_hash,
|
||||
"owner": owner,
|
||||
"roles": roles,
|
||||
"created_at": created_at,
|
||||
}
|
||||
table = _get_dynamodb().Table(USERS_TABLE)
|
||||
table.put_item(TableName=USERS_TABLE, Item=item)
|
||||
_emit_audit("auth.sign_up", user_id=user_id, email=email)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "sign_up",
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
class _DuplicateEmailError(Exception):
|
||||
"""Raised when sign_up is called with an already-registered email → 409."""
|
||||
|
||||
def __init__(self, email: str):
|
||||
self.email = email
|
||||
super().__init__(f"email already registered: {email}")
|
||||
|
||||
|
||||
def create_session(user_id: str) -> str:
|
||||
"""Create a session row in nova-sessions; return the session_id.
|
||||
|
||||
TTL: expires_at = now + SESSION_TTL_SECONDS (epoch seconds).
|
||||
"""
|
||||
session_id = str(uuid.uuid4())
|
||||
now = _epoch_now()
|
||||
expires_at = now + SESSION_TTL_SECONDS
|
||||
created_at = _iso8601_now()
|
||||
table = _get_dynamodb().Table(SESSIONS_TABLE)
|
||||
table.put_item(
|
||||
TableName=SESSIONS_TABLE,
|
||||
Item={
|
||||
"session_id": session_id,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at,
|
||||
"created_at": created_at,
|
||||
},
|
||||
)
|
||||
_emit_audit("auth.session_created", user_id=user_id, session_id=session_id)
|
||||
return session_id
|
||||
|
||||
|
||||
def sign_in(payload):
|
||||
"""Sign in by email + password → return a session_id.
|
||||
|
||||
On wrong password → raises VerifyMismatchError (→ 401).
|
||||
On unknown email → raises _UnknownUserError (→ 401, same code to
|
||||
avoid user-enumeration via timing — the message is generic).
|
||||
On argon2 unavailable → Argon2UnavailableError (→ 503).
|
||||
"""
|
||||
_require(("email", "password"), payload)
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError("argon2 unavailable")
|
||||
email = payload["email"]
|
||||
password = payload["password"]
|
||||
user = _lookup_user_by_email(email)
|
||||
if user is None:
|
||||
# Generic 401 — do not reveal whether the email is registered
|
||||
# (user-enumeration defense).
|
||||
raise _UnknownUserError("invalid credentials")
|
||||
try:
|
||||
verify_password(password, user["password_hash"])
|
||||
except VerifyMismatchError:
|
||||
raise _UnknownUserError("invalid credentials")
|
||||
session_id = create_session(user["user_id"])
|
||||
_emit_audit("auth.sign_in", user_id=user["user_id"], email=email)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "sign_in",
|
||||
"user_id": user["user_id"],
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
|
||||
class _UnknownUserError(Exception):
|
||||
"""Generic 'invalid credentials' — 401 (no user enumeration)."""
|
||||
|
||||
|
||||
def request_password_reset(payload):
|
||||
"""Generate a reset token (uuid4) → write to nova-password-resets (15 min TTL).
|
||||
|
||||
Returns the token directly (in a real system this would be emailed;
|
||||
for v1.28 it is returned so tests / the CLI can drive reset_password).
|
||||
"""
|
||||
_require(("email",), payload)
|
||||
email = payload["email"]
|
||||
user = _lookup_user_by_email(email)
|
||||
if user is None:
|
||||
# Return ok regardless (no user enumeration via reset endpoint).
|
||||
# We still return a (fake) token shape so the response is uniform;
|
||||
# the token is single-use and reset_password validates against DDB.
|
||||
_emit_audit("auth.password_reset_requested", email=email, found=False)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "request_password_reset",
|
||||
"reset_token": None,
|
||||
"message": "if the email is registered, a reset token was issued",
|
||||
}
|
||||
reset_token = str(uuid.uuid4())
|
||||
now = _epoch_now()
|
||||
expires_at = now + RESET_TTL_SECONDS
|
||||
table = _get_dynamodb().Table(PASSWORD_RESETS_TABLE)
|
||||
table.put_item(
|
||||
TableName=PASSWORD_RESETS_TABLE,
|
||||
Item={
|
||||
"reset_token": reset_token,
|
||||
"user_id": user["user_id"],
|
||||
"expires_at": expires_at,
|
||||
"created_at": _iso8601_now(),
|
||||
},
|
||||
)
|
||||
_emit_audit(
|
||||
"auth.password_reset_requested",
|
||||
user_id=user["user_id"],
|
||||
email=email,
|
||||
found=True,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "request_password_reset",
|
||||
"reset_token": reset_token,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
def reset_password(payload):
|
||||
"""Validate a reset token → set a new password → delete the token.
|
||||
|
||||
Payload: { reset_token, new_password }
|
||||
On invalid/expired token → ValueError (→ 400).
|
||||
On argon2 unavailable → Argon2UnavailableError (→ 503).
|
||||
"""
|
||||
_require(("reset_token", "new_password"), payload)
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError("argon2 unavailable")
|
||||
reset_token = payload["reset_token"]
|
||||
new_password = payload["new_password"]
|
||||
resets = _get_dynamodb().Table(PASSWORD_RESETS_TABLE)
|
||||
resp = resets.get_item(
|
||||
TableName=PASSWORD_RESETS_TABLE,
|
||||
Key={"reset_token": reset_token},
|
||||
)
|
||||
item = resp.get("Item")
|
||||
if not item:
|
||||
raise ValueError("invalid or expired reset token")
|
||||
if item.get("expires_at", 0) < _epoch_now():
|
||||
# Token expired (TTL may not have reaped it yet).
|
||||
raise ValueError("reset token expired")
|
||||
user_id = item["user_id"]
|
||||
new_hash = hash_password(new_password) # fail-closed
|
||||
users = _get_dynamodb().Table(USERS_TABLE)
|
||||
users.update_item(
|
||||
TableName=USERS_TABLE,
|
||||
Key={"user_id": user_id},
|
||||
UpdateExpression="SET password_hash = :h",
|
||||
ExpressionAttributeValues={":h": new_hash},
|
||||
)
|
||||
resets.delete_item(
|
||||
TableName=PASSWORD_RESETS_TABLE,
|
||||
Key={"reset_token": reset_token},
|
||||
)
|
||||
_emit_audit("auth.password_reset", user_id=user_id)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "reset_password",
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch (shared by Lambda handler + CLI — REQ-329 dual-use)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def dispatch_action(payload, event=None):
|
||||
"""Shared business-logic dispatch for the IdP auth Lambda (REQ-329).
|
||||
|
||||
Both the AWS Lambda handler (``lambda_handler``) and the CLI path
|
||||
(``cli_main`` / ``__main__``) call this so the two paths share a
|
||||
single source of truth for action routing.
|
||||
|
||||
Args:
|
||||
payload: the decoded action envelope dict, e.g.
|
||||
``{ action: "sign_up", email, password, owner, roles }``.
|
||||
event: the raw Lambda Function-URL event (unused for identity —
|
||||
the IAM auth is enforced at the Function URL layer; kept for
|
||||
signature symmetry with contract_ingestor).
|
||||
|
||||
Returns:
|
||||
The action result dict on success. Raises on error — the caller
|
||||
maps exceptions to status codes via :func:`_to_http_response`.
|
||||
"""
|
||||
action = payload.get("action")
|
||||
if action == "sign_up":
|
||||
return sign_up(payload)
|
||||
if action == "sign_in":
|
||||
return sign_in(payload)
|
||||
if action == "create_session":
|
||||
_require(("user_id",), payload)
|
||||
sid = create_session(payload["user_id"])
|
||||
return {"status": "ok", "action": "create_session", "session_id": sid}
|
||||
if action == "request_password_reset":
|
||||
return request_password_reset(payload)
|
||||
if action == "reset_password":
|
||||
return reset_password(payload)
|
||||
raise ValueError(f"unknown action: {action!r}")
|
||||
|
||||
|
||||
def _to_http_response(result_or_error):
|
||||
"""Map a dispatch result / exception to a Lambda HTTP response."""
|
||||
if isinstance(result_or_error, Exception):
|
||||
# Fail-closed: argon2 unavailable → 503 (NO weak hash, NO crash).
|
||||
if isinstance(result_or_error, Argon2UnavailableError):
|
||||
return {
|
||||
"statusCode": 503,
|
||||
"body": json.dumps({"error": "argon2_unavailable"}),
|
||||
}
|
||||
if isinstance(result_or_error, _DuplicateEmailError):
|
||||
return {
|
||||
"statusCode": 409,
|
||||
"body": json.dumps({"error": "email_already_registered"}),
|
||||
}
|
||||
if isinstance(result_or_error, _UnknownUserError):
|
||||
return {
|
||||
"statusCode": 401,
|
||||
"body": json.dumps({"error": "invalid_credentials"}),
|
||||
}
|
||||
if isinstance(result_or_error, ValueError):
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"body": json.dumps({"error": str(result_or_error)}),
|
||||
}
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"body": json.dumps({"error": str(result_or_error)}),
|
||||
}
|
||||
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use).
|
||||
|
||||
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
||||
containing ``{ action, email, password, ... }``. Parses the envelope
|
||||
then delegates to :func:`dispatch_action`.
|
||||
"""
|
||||
# Fail-closed fast-path: if argon2 is unavailable, sign_up / sign_in /
|
||||
# reset_password all raise Argon2UnavailableError which maps to 503.
|
||||
# We do NOT short-circuit here so non-password actions (create_session)
|
||||
# still work when argon2 is down — only the hashing paths fail closed.
|
||||
try:
|
||||
body = event.get("body", "{}")
|
||||
payload = json.loads(body) if isinstance(body, str) else body
|
||||
result = dispatch_action(payload, event=event)
|
||||
return _to_http_response(result)
|
||||
except Exception as e:
|
||||
return _to_http_response(e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI (dual-use, REQ-329 pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cli_main(argv=None):
|
||||
"""CLI entry point for the IdP auth Lambda (REQ-329 dual-use).
|
||||
|
||||
Usage:
|
||||
python3 -m core.lambda.nova_idp_auth --sign-up <email> <password> <owner>
|
||||
python3 -m core.lambda.nova_idp_auth --sign-in <email> <password>
|
||||
python3 -m core.lambda.nova_idp_auth --create-session <user_id>
|
||||
python3 -m core.lambda.nova_idp_auth --request-reset <email>
|
||||
python3 -m core.lambda.nova_idp_auth --reset-password <token> <new_password>
|
||||
python3 -m core.lambda.nova_idp_auth --dispatch <payload.json>
|
||||
python3 -m core.lambda.nova_idp_auth --dispatch-stdin < <payload.json>
|
||||
"""
|
||||
import sys
|
||||
|
||||
raw = argv if argv is not None else sys.argv[1:]
|
||||
local_bypass = os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
|
||||
if not local_bypass:
|
||||
os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
|
||||
try:
|
||||
if "--dispatch-stdin" in raw:
|
||||
payload = json.loads(sys.stdin.read())
|
||||
elif "--dispatch" in raw:
|
||||
idx = raw.index("--dispatch")
|
||||
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||
if not path:
|
||||
print("Usage: --dispatch <payload.json>", file=sys.stderr)
|
||||
return 2
|
||||
with open(path) as fh:
|
||||
payload = json.loads(fh.read())
|
||||
elif "--sign-up" in raw:
|
||||
idx = raw.index("--sign-up")
|
||||
email, password, owner = raw[idx + 1 : idx + 4]
|
||||
roles = ["user"]
|
||||
payload = {
|
||||
"action": "sign_up",
|
||||
"email": email,
|
||||
"password": password,
|
||||
"owner": owner,
|
||||
"roles": roles,
|
||||
}
|
||||
elif "--sign-in" in raw:
|
||||
idx = raw.index("--sign-in")
|
||||
email, password = raw[idx + 1 : idx + 3]
|
||||
payload = {"action": "sign_in", "email": email, "password": password}
|
||||
elif "--create-session" in raw:
|
||||
idx = raw.index("--create-session")
|
||||
user_id = raw[idx + 1]
|
||||
payload = {"action": "create_session", "user_id": user_id}
|
||||
elif "--request-reset" in raw:
|
||||
idx = raw.index("--request-reset")
|
||||
email = raw[idx + 1]
|
||||
payload = {"action": "request_password_reset", "email": email}
|
||||
elif "--reset-password" in raw:
|
||||
idx = raw.index("--reset-password")
|
||||
token, new_password = raw[idx + 1 : idx + 3]
|
||||
payload = {
|
||||
"action": "reset_password",
|
||||
"reset_token": token,
|
||||
"new_password": new_password,
|
||||
}
|
||||
else:
|
||||
print(
|
||||
"Usage: python3 -m core.lambda.nova_idp_auth "
|
||||
"--sign-up <email> <password> <owner> | "
|
||||
"--sign-in <email> <password> | "
|
||||
"--dispatch <payload.json>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
result = dispatch_action(payload, event=None)
|
||||
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||
return 0
|
||||
except Argon2UnavailableError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 3 # 503-class
|
||||
except ValueError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1
|
||||
except _DuplicateEmailError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 9 # 409-class
|
||||
except _UnknownUserError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1 # 401-class
|
||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||
sys.stderr.write(f"internal error: {e}\n")
|
||||
return 2
|
||||
finally:
|
||||
if not local_bypass:
|
||||
os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||
import sys
|
||||
|
||||
sys.exit(cli_main())
|
||||
@@ -0,0 +1,244 @@
|
||||
"""CloudFormation snippet for the Nova IdP DynamoDB identity schema (REQ-335).
|
||||
|
||||
This module exports :func:`dynamodb_tables_snippet`, which returns a
|
||||
CloudFormation fragment (a plain ``dict``) defining the four DynamoDB
|
||||
tables that back the Nova identity provider:
|
||||
|
||||
* ``nova-users`` — user records (PK ``user_id``, GSI1 ``email``)
|
||||
* ``nova-sessions`` — session tokens (PK ``session_id``, GSI1
|
||||
``user_id``, TTL ``expires_at``)
|
||||
* ``nova-password-resets`` — reset tokens (PK ``reset_token``, TTL
|
||||
``expires_at`` — 15 min)
|
||||
* ``nova-pats`` — personal access tokens (PK ``jti``, GSI1
|
||||
``sub``, GSI2 ``pat_hash``). This table is consumed in P4 (OIDC/PAT
|
||||
issuance) but is defined here so a single ``nova idp setup``
|
||||
CloudFormation template provisions the complete identity backend.
|
||||
|
||||
Design notes (REQ-335):
|
||||
* All tables use ``BillingMode: PAY_PER_REQUEST`` (on-demand) — the
|
||||
IdP traffic is bursty and unpredictable; provisioned capacity would
|
||||
either throttle or waste money.
|
||||
* PITR (``PointInTimeRecoverySpecification``) is enabled on
|
||||
``nova-users`` — user records are irreplaceable; continuous backup
|
||||
protects against accidental deletes / corrupt writes. The session /
|
||||
reset / PAT tables are ephemeral (TTL-managed) so PITR is not
|
||||
required there, but enabling it is cheap insurance; we enable it on
|
||||
``nova-users`` per REQ-335 and leave the others as on-demand only
|
||||
(TTL is the recovery mechanism for those).
|
||||
* TTL attributes (``expires_at``) are epoch seconds — DynamoDB TTL
|
||||
silently deletes expired items in the background (best-effort, do
|
||||
not rely on for access control; the handler also checks ``expires_at``
|
||||
on read).
|
||||
|
||||
The fragment is composed into the full ``nova idp setup`` template in
|
||||
P4 Wave 8 (``nova idp setup --apply``). The keys in the returned dict
|
||||
are CloudFormation logical resource IDs (``NovaUsersTable``, etc.) so
|
||||
the composer can merge it directly into a template's ``Resources``
|
||||
section.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _attribute(name: str, attr_type: str = "S") -> Dict[str, str]:
|
||||
return {"AttributeName": name, "AttributeType": attr_type}
|
||||
|
||||
|
||||
def _key_schema(name: str, key_type: str = "HASH") -> Dict[str, str]:
|
||||
return {"AttributeName": name, "KeyType": key_type}
|
||||
|
||||
|
||||
def dynamodb_tables_snippet() -> Dict[str, Dict[str, Any]]:
|
||||
"""Return a CloudFormation fragment defining the four IdP DynamoDB tables.
|
||||
|
||||
The returned dict maps logical resource IDs to CloudFormation
|
||||
resource dicts (``Type: AWS::DynamoDB::Table``). It is intended to be
|
||||
merged into the ``Resources`` block of the full
|
||||
``nova idp setup`` template (P4 Wave 8).
|
||||
|
||||
Tables:
|
||||
* ``NovaUsersTable`` (``nova-users``)
|
||||
* ``NovaSessionsTable`` (``nova-sessions``)
|
||||
* ``NovaPasswordResetsTable`` (``nova-password-resets``)
|
||||
* ``NovaPatsTable`` (``nova-pats``)
|
||||
|
||||
All tables are ``PAY_PER_REQUEST`` (on-demand). PITR is enabled on
|
||||
``nova-users`` (REQ-335). TTL is enabled on the three ephemeral
|
||||
tables (``expires_at`` epoch-seconds attribute).
|
||||
"""
|
||||
return {
|
||||
# -----------------------------------------------------------------
|
||||
# nova-users — the user directory (PK user_id, GSI1 email).
|
||||
# PITR enabled: user records are irreplaceable.
|
||||
# -----------------------------------------------------------------
|
||||
"NovaUsersTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-users",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
_key_schema("user_id", "HASH"),
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
_attribute("user_id", "S"),
|
||||
_attribute("email", "S"),
|
||||
],
|
||||
"GlobalSecondaryIndexes": [
|
||||
{
|
||||
"IndexName": "email-index",
|
||||
"KeySchema": [_key_schema("email", "HASH")],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
},
|
||||
],
|
||||
"PointInTimeRecoverySpecification": {
|
||||
"PointInTimeRecoveryEnabled": True,
|
||||
},
|
||||
# Attribute shape (for documentation / the setup --dry-run
|
||||
# summary; DynamoDB is schemaless so this is not enforced):
|
||||
# user_id String (PK)
|
||||
# email String (GSI1 hash, unique)
|
||||
# password_hash String (Argon2id, never the raw password)
|
||||
# owner String
|
||||
# roles List
|
||||
# created_at String (ISO-8601)
|
||||
"AttributeShape": {
|
||||
"user_id": "String",
|
||||
"email": "String",
|
||||
"password_hash": "String",
|
||||
"owner": "String",
|
||||
"roles": "List",
|
||||
"created_at": "String",
|
||||
},
|
||||
},
|
||||
},
|
||||
# -----------------------------------------------------------------
|
||||
# nova-sessions — session tokens (PK session_id, GSI1 user_id).
|
||||
# TTL: expires_at (epoch seconds). Sessions live 24h.
|
||||
# -----------------------------------------------------------------
|
||||
"NovaSessionsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-sessions",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
_key_schema("session_id", "HASH"),
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
_attribute("session_id", "S"),
|
||||
_attribute("user_id", "S"),
|
||||
],
|
||||
"GlobalSecondaryIndexes": [
|
||||
{
|
||||
"IndexName": "user_id-index",
|
||||
"KeySchema": [_key_schema("user_id", "HASH")],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
},
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": True,
|
||||
},
|
||||
"AttributeShape": {
|
||||
"session_id": "String",
|
||||
"user_id": "String",
|
||||
"expires_at": "String (epoch seconds, TTL)",
|
||||
"created_at": "String (ISO-8601)",
|
||||
},
|
||||
},
|
||||
},
|
||||
# -----------------------------------------------------------------
|
||||
# nova-password-resets — reset tokens (PK reset_token).
|
||||
# TTL: expires_at (epoch seconds). Tokens live 15 min.
|
||||
# -----------------------------------------------------------------
|
||||
"NovaPasswordResetsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-password-resets",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
_key_schema("reset_token", "HASH"),
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
_attribute("reset_token", "S"),
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": True,
|
||||
},
|
||||
"AttributeShape": {
|
||||
"reset_token": "String",
|
||||
"user_id": "String",
|
||||
"expires_at": "String (epoch seconds, TTL; 15 min)",
|
||||
},
|
||||
},
|
||||
},
|
||||
# -----------------------------------------------------------------
|
||||
# nova-pats — personal access tokens (PK jti, GSI1 sub, GSI2 pat_hash).
|
||||
# Consumed in P4 (OIDC/PAT issuance) but defined here so the single
|
||||
# CloudFormation template provisions the complete identity backend.
|
||||
# TTL: expires_at (epoch seconds).
|
||||
# -----------------------------------------------------------------
|
||||
"NovaPatsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-pats",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
_key_schema("jti", "HASH"),
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
_attribute("jti", "S"),
|
||||
_attribute("sub", "S"),
|
||||
_attribute("pat_hash", "S"),
|
||||
],
|
||||
"GlobalSecondaryIndexes": [
|
||||
{
|
||||
"IndexName": "sub-index",
|
||||
"KeySchema": [_key_schema("sub", "HASH")],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
},
|
||||
{
|
||||
"IndexName": "pat_hash-index",
|
||||
"KeySchema": [_key_schema("pat_hash", "HASH")],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
},
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": True,
|
||||
},
|
||||
"AttributeShape": {
|
||||
"jti": "String (PK)",
|
||||
"sub": "String (GSI1; subject / user_id)",
|
||||
"pat_hash": "String (GSI2; SHA-256 of the PAT for lookup)",
|
||||
"status": "String (active|revoked)",
|
||||
"issued_at": "String (ISO-8601)",
|
||||
"expires_at": "String (epoch seconds, TTL)",
|
||||
"revoked_at": "String (ISO-8601, present iff status=revoked)",
|
||||
"claims": "Map (JWT claims payload)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def table_names() -> Dict[str, str]:
|
||||
"""Return the logical→physical table-name mapping (for env-var defaults)."""
|
||||
return {
|
||||
"users": "nova-users",
|
||||
"sessions": "nova-sessions",
|
||||
"password_resets": "nova-password-resets",
|
||||
"pats": "nova-pats",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
import json
|
||||
import sys
|
||||
|
||||
if "--names" in sys.argv:
|
||||
sys.stdout.write(json.dumps(table_names(), indent=2) + "\n")
|
||||
else:
|
||||
sys.stdout.write(json.dumps(dynamodb_tables_snippet(), indent=2) + "\n")
|
||||
@@ -0,0 +1,236 @@
|
||||
"""CloudFormation template for the Nova IdP (REQ-340, REQ-341, C-2.1).
|
||||
|
||||
Composes the DynamoDB snippet (from P3 ``nova_idp_auth_cfn.py``) + 3
|
||||
Lambdas (``nova-idp-auth``, ``nova-idp-token-vend``, ``nova-idp-jwks``)
|
||||
+ KMS key (``alias/nova-oidc-signing``, ``ECC_NIST_P256``,
|
||||
``SIGN_VERIFY``) + function URLs + IAM roles + optional
|
||||
CloudFront/WAF/ACM (when ``public_jwks_domain`` is provided).
|
||||
|
||||
:func:`generate_template` returns a CloudFormation template dict (no
|
||||
troposphere dependency — raw dict → JSON).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _load_auth_cfn():
|
||||
"""Load core/lambda/nova_idp_auth_cfn.py via importlib (`lambda` is reserved)."""
|
||||
p = Path(__file__).parent / "nova_idp_auth_cfn.py"
|
||||
spec = importlib.util.spec_from_file_location("nova_idp_auth_cfn", p)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_auth_cfn = _load_auth_cfn()
|
||||
dynamodb_tables_snippet = _auth_cfn.dynamodb_tables_snippet
|
||||
table_names = _auth_cfn.table_names
|
||||
|
||||
|
||||
def _lambda_role(logical_id: str, table_envs: dict[str, str], kms: bool = False) -> dict:
|
||||
"""Build an IAM role for a Nova IdP Lambda."""
|
||||
statements = [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
|
||||
"Resource": {"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"},
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["logs:CreateLogGroup"],
|
||||
"Resource": {"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"},
|
||||
},
|
||||
]
|
||||
if table_envs:
|
||||
statements.append({
|
||||
"Effect": "Allow",
|
||||
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem",
|
||||
"dynamodb:Query", "dynamodb:DeleteItem"],
|
||||
"Resource": [
|
||||
{"Fn::Sub": f"arn:aws:dynamodb:${{AWS::Region}}:${{AWS::AccountId}}:table/{name}"}
|
||||
for name in table_envs.values()
|
||||
],
|
||||
})
|
||||
if kms:
|
||||
statements.append({
|
||||
"Effect": "Allow",
|
||||
"Action": ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],
|
||||
"Resource": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||
})
|
||||
return {
|
||||
"Type": "AWS::IAM::Role",
|
||||
"Properties": {
|
||||
"AssumeRolePolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": {"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"}},
|
||||
"Action": "sts:AssumeRole",
|
||||
}],
|
||||
},
|
||||
"Policies": [{"PolicyName": f"{logical_id}Policy", "PolicyDocument": {
|
||||
"Version": "2012-10-17", "Statement": statements,
|
||||
}}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _lambda_function(logical_id: str, handler: str, role_ref: str,
|
||||
env_vars: dict[str, str], memory: int = 512) -> dict:
|
||||
return {
|
||||
"Type": "AWS::Lambda::Function",
|
||||
"Properties": {
|
||||
"Handler": handler,
|
||||
"Runtime": "python3.12",
|
||||
"MemorySize": memory,
|
||||
"Timeout": 30,
|
||||
"Role": {"Fn::GetAtt": [role_ref, "Arn"]},
|
||||
"Environment": {"Variables": env_vars},
|
||||
"Code": {"ZipFile": "def lambda_handler(event, context):\n return {}"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _function_url(logical_id: str, auth_type: str = "AWS_IAM") -> dict:
|
||||
return {
|
||||
"Type": "AWS::Lambda::Url",
|
||||
"Properties": {
|
||||
"TargetFunction": {"Ref": logical_id},
|
||||
"AuthType": auth_type,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_template(public_jwks_domain: str | None = None) -> Dict[str, Any]:
|
||||
"""Generate the full Nova IdP CloudFormation template (REQ-340).
|
||||
|
||||
Args:
|
||||
public_jwks_domain: optional custom domain for the JWKS endpoint.
|
||||
When provided, CloudFront + ACM + WAF resources are added.
|
||||
|
||||
Returns:
|
||||
A CloudFormation template dict (``{"Resources": {...}}``).
|
||||
"""
|
||||
resources: Dict[str, Any] = {}
|
||||
# DynamoDB tables (from P3).
|
||||
resources.update(dynamodb_tables_snippet())
|
||||
names = table_names()
|
||||
|
||||
# KMS key (ECC_NIST_P256, SIGN_VERIFY) + alias.
|
||||
resources["NovaOidcSigningKey"] = {
|
||||
"Type": "AWS::KMS::Key",
|
||||
"Properties": {
|
||||
"Description": "Nova OIDC token signing key (REQ-337, ECC_NIST_P256)",
|
||||
"KeySpec": "ECC_NIST_P256",
|
||||
"KeyUsage": "SIGN_VERIFY",
|
||||
"KeyPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": {"Fn::Sub": "arn:aws:iam::${AWS::AccountId}:root"}},
|
||||
"Action": "kms:*",
|
||||
"Resource": "*",
|
||||
}],
|
||||
},
|
||||
},
|
||||
}
|
||||
resources["NovaOidcSigningKeyAlias"] = {
|
||||
"Type": "AWS::KMS::Alias",
|
||||
"Properties": {
|
||||
"AliasName": "alias/nova-oidc-signing",
|
||||
"TargetKeyId": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||
},
|
||||
}
|
||||
|
||||
# Lambda roles.
|
||||
auth_tables = {"users": names["users"], "sessions": names["sessions"],
|
||||
"password_resets": names["password_resets"]}
|
||||
resources["NovaIdpAuthRole"] = _lambda_role("NovaIdpAuth", auth_tables)
|
||||
resources["NovaIdpTokenVendRole"] = _lambda_role(
|
||||
"NovaIdpTokenVend", {"pats": names["pats"]}, kms=True)
|
||||
resources["NovaIdpJwksRole"] = _lambda_role("NovaIdpJwks", {}, kms=True)
|
||||
|
||||
# Lambda functions.
|
||||
common_env = {
|
||||
"NOVA_USERS_TABLE": names["users"],
|
||||
"NOVA_SESSIONS_TABLE": names["sessions"],
|
||||
"NOVA_PASSWORD_RESETS_TABLE": names["password_resets"],
|
||||
"NOVA_PATS_TABLE": names["pats"],
|
||||
}
|
||||
resources["NovaIdpAuthFunction"] = _lambda_function(
|
||||
"NovaIdpAuth", "nova_idp_auth.lambda_handler", "NovaIdpAuthRole", common_env)
|
||||
resources["NovaIdpTokenVendFunction"] = _lambda_function(
|
||||
"NovaIdpTokenVend", "nova_idp_token_vend.lambda_handler", "NovaIdpTokenVendRole",
|
||||
{**common_env, "NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"})
|
||||
resources["NovaIdpJwksFunction"] = _lambda_function(
|
||||
"NovaIdpJwks", "nova_idp_jwks.lambda_handler", "NovaIdpJwksRole",
|
||||
{"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"}, memory=256)
|
||||
|
||||
# Function URLs (auth Lambda: IAM; token-vend: IAM; jwks: NONE — public).
|
||||
resources["NovaIdpAuthUrl"] = _function_url("NovaIdpAuthFunction", "AWS_IAM")
|
||||
resources["NovaIdpTokenVendUrl"] = _function_url("NovaIdpTokenVendFunction", "AWS_IAM")
|
||||
resources["NovaIdpJwksUrl"] = _function_url("NovaIdpJwksFunction", "NONE")
|
||||
|
||||
# Optional: CloudFront + ACM + WAF for a custom JWKS domain.
|
||||
if public_jwks_domain:
|
||||
resources["NovaJwksCloudFront"] = {
|
||||
"Type": "AWS::CloudFront::Distribution",
|
||||
"Properties": {
|
||||
"DistributionConfig": {
|
||||
"Enabled": True,
|
||||
"Aliases": [public_jwks_domain],
|
||||
"Origins": [{
|
||||
"DomainName": {"Fn::GetAtt": "NovaIdpJwksUrl.Endpoint"},
|
||||
"Id": "JwksOrigin",
|
||||
"CustomOriginConfig": {"OriginProtocolPolicy": "https-only"},
|
||||
}],
|
||||
"DefaultCacheBehavior": {
|
||||
"TargetOriginId": "JwksOrigin",
|
||||
"ViewerProtocolPolicy": "redirect-to-https",
|
||||
"ForwardedValues": {"QueryString": False},
|
||||
},
|
||||
"ViewerCertificate": {
|
||||
"AcmCertificateArn": {"Ref": "NovaJwksAcmCert"},
|
||||
"SslSupportMethod": "sni-only",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
resources["NovaJwksAcmCert"] = {
|
||||
"Type": "AWS::CertificateManager::Certificate",
|
||||
"Properties": {"DomainName": public_jwks_domain,
|
||||
"ValidationMethod": "DNS"},
|
||||
}
|
||||
resources["NovaJwksWafRateRule"] = {
|
||||
"Type": "AWS::WAFv2::RateBasedRule",
|
||||
"Properties": {
|
||||
"Name": "nova-jwks-rate-limit",
|
||||
"Scope": "CLOUDFRONT",
|
||||
"RateLimit": 100,
|
||||
"Action": {"Block": {}},
|
||||
"ComparisonOperator": "GreaterThan",
|
||||
"AggregateKeyType": "IP",
|
||||
"DefaultCaptchaConfig": {"ImmunityTimeProperty": {"ImmunityTime": 60}},
|
||||
},
|
||||
}
|
||||
|
||||
return {"Resources": resources}
|
||||
|
||||
|
||||
def resource_summary(template: dict) -> dict[str, int]:
|
||||
"""Return ``{resource_type: count}`` for a template (for --dry-run)."""
|
||||
counts: dict[str, int] = {}
|
||||
for res in template.get("Resources", {}).values():
|
||||
t = res.get("Type", "Unknown")
|
||||
counts[t] = counts.get(t, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
import json, sys
|
||||
domain = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
print(json.dumps(generate_template(domain), indent=2))
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Nova IdP JWKS endpoint Lambda (REQ-338, D-230).
|
||||
|
||||
Serves the KMS public key as a JWK in a standard JWKS response. The
|
||||
endpoint is a Lambda function URL with ``AuthType: NONE`` (JWKS is
|
||||
public-key only — configured in CloudFormation, not in code).
|
||||
|
||||
Response:
|
||||
* ``Content-Type: application/json``
|
||||
* ``Cache-Control: public, max-age=3600`` (1h — clients cache the JWKS)
|
||||
* ``Access-Control-Allow-Origin: *`` (JWKS is public)
|
||||
* ``body: {"keys": [<jwk>]}``
|
||||
|
||||
The JWK is built via :func:`core.kms_signing.get_jwk` from the KMS
|
||||
public key (DER SPKI → ``cryptography`` → JWK).
|
||||
|
||||
Dual-use (REQ-329): ``__main__`` CLI block for local testing
|
||||
(``--print-jwks``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler — serve the JWKS response (REQ-338)."""
|
||||
try:
|
||||
from core.kms_signing import get_jwk
|
||||
jwk = get_jwk(key_id=OIDC_KMS_KEY_ID)
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
"body": json.dumps({"keys": [jwk]}),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": json.dumps({"error": str(e)}),
|
||||
}
|
||||
|
||||
|
||||
def cli_main(argv=None):
|
||||
"""CLI entry point (REQ-329 dual-use). ``--print-jwks`` → stdout."""
|
||||
raw = argv if argv is not None else sys.argv[1:]
|
||||
if "--print-jwks" in raw:
|
||||
resp = lambda_handler({}, None)
|
||||
sys.stdout.write(resp["body"] + "\n")
|
||||
return resp.get("statusCode", 200) - 200
|
||||
print("Usage: python3 -m core.lambda.nova_idp_jwks --print-jwks", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||
sys.exit(cli_main())
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Nova IdP setup logic — check / apply / verify (REQ-340, REQ-341, C-2.1).
|
||||
|
||||
Backing logic for ``nova idp setup``. The CLI (``nova/idp/setup.py``)
|
||||
is a thin ≤50-line delegate to this module (CAP-034).
|
||||
|
||||
From v1.29 (REQ-369, spec §7.5) the active provisioning path is
|
||||
``terraform apply`` in the ``nova-platform-ops`` checkout. The CFN
|
||||
template generated here is archived as read-only reference in
|
||||
``docs/archive/nova-idp-cfn-v1.28.md``; :func:`generate_and_deploy`
|
||||
(the former CFN deploy path) emits a ``DeprecationWarning`` and is
|
||||
retained only as a fallback when terraform is absent from PATH.
|
||||
:func:`terraform_apply` and :func:`terraform_plan` are the new
|
||||
preferred paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
_CFN_ARCHIVE_REF = (
|
||||
"CFN path is archived; install terraform or use nova-platform-ops. "
|
||||
"See docs/archive/nova-idp-cfn-v1.28.md."
|
||||
)
|
||||
|
||||
|
||||
def _load_cfn():
|
||||
"""Load core/lambda/nova_idp_cfn.py via importlib (`lambda` is reserved)."""
|
||||
p = Path(__file__).parent / "nova_idp_cfn.py"
|
||||
spec = importlib.util.spec_from_file_location("nova_idp_cfn", p)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_cfn = _load_cfn()
|
||||
generate_template = _cfn.generate_template
|
||||
resource_summary = _cfn.resource_summary
|
||||
|
||||
|
||||
def check_prerequisites() -> dict[str, Any]:
|
||||
"""Check IdP setup prerequisites (AWS creds, CFN/IAM/KMS perms).
|
||||
|
||||
Returns a report dict:
|
||||
``{"aws_creds": bool, "region": str|None, "missing": [str], "iam_delta": [str]}``
|
||||
"""
|
||||
report: dict[str, Any] = {"aws_creds": False, "region": None, "missing": [], "iam_delta": []}
|
||||
# AWS creds check.
|
||||
try:
|
||||
who = subprocess.check_output(
|
||||
["aws", "sts", "get-caller-identity"], stderr=subprocess.DEVNULL, text=True, timeout=10
|
||||
)
|
||||
report["aws_creds"] = bool(json.loads(who).get("Account"))
|
||||
except Exception:
|
||||
report["missing"].append("aws_credentials (run `aws configure`)")
|
||||
# Region.
|
||||
region = os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION")
|
||||
report["region"] = region
|
||||
if not region:
|
||||
report["missing"].append("aws_region (set AWS_DEFAULT_REGION)")
|
||||
# IAM policy delta (the grants the deploying principal needs).
|
||||
report["iam_delta"] = [
|
||||
"cloudformation:*",
|
||||
"iam:CreateRole",
|
||||
"iam:PassRole",
|
||||
"lambda:CreateFunction",
|
||||
"lambda:CreateFunctionUrlConfig",
|
||||
"dynamodb:CreateTable",
|
||||
"kms:CreateKey",
|
||||
"kms:CreateAlias",
|
||||
]
|
||||
return report
|
||||
|
||||
|
||||
def generate_and_deploy(
|
||||
public_jwks_domain: str | None = None,
|
||||
dry_run: bool = False,
|
||||
approve_fn=None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate the CFN template + deploy (REQ-341, NFR-10 y/N approval).
|
||||
|
||||
.. deprecated:: v1.29
|
||||
The active path is :func:`terraform_apply` (REQ-369, spec §7.5).
|
||||
This CFN deploy path is archived as read-only reference in
|
||||
``docs/archive/nova-idp-cfn-v1.28.md`` and retained only as a
|
||||
fallback when terraform is absent from PATH. It emits a
|
||||
``DeprecationWarning`` on every non-dry-run invocation.
|
||||
|
||||
Args:
|
||||
public_jwks_domain: optional custom JWKS domain.
|
||||
dry_run: if True, print the resource summary only (no deploy).
|
||||
approve_fn: callable returning True/False for the y/N prompt
|
||||
(defaults to stdin readline).
|
||||
|
||||
Returns:
|
||||
``{"template": <dict>, "summary": <dict>, "deployed": bool}``.
|
||||
"""
|
||||
template = generate_template(public_jwks_domain)
|
||||
summary = resource_summary(template)
|
||||
if dry_run:
|
||||
return {"template": template, "summary": summary, "deployed": False}
|
||||
warnings.warn(_CFN_ARCHIVE_REF, DeprecationWarning, stacklevel=2)
|
||||
# NFR-10: explicit y/N approval before cloudformation deploy.
|
||||
print("Resource summary:")
|
||||
for rtype, count in sorted(summary.items()):
|
||||
print(f" {rtype}: {count}")
|
||||
# Print template to a temp file + open $PAGER.
|
||||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8")
|
||||
json.dump(template, tmp, indent=2); tmp.flush(); tmp.close()
|
||||
pager = os.environ.get("PAGER")
|
||||
if pager and sys.stdin.isatty():
|
||||
try:
|
||||
subprocess.run([pager, tmp.name])
|
||||
except Exception:
|
||||
print(f"(template at {tmp.name})")
|
||||
else:
|
||||
print(f"(template at {tmp.name})")
|
||||
# y/N prompt.
|
||||
if approve_fn is None:
|
||||
answer = input("Apply? [y/N] ").strip().lower()
|
||||
else:
|
||||
answer = "y" if approve_fn() else "n"
|
||||
if answer != "y":
|
||||
print("aborted (no approval)")
|
||||
return {"template": template, "summary": summary, "deployed": False}
|
||||
# cloudformation deploy.
|
||||
stack_name = os.environ.get("NOVA_IDP_STACK_NAME", "nova-idp")
|
||||
try:
|
||||
subprocess.check_call([
|
||||
"aws", "cloudformation", "deploy",
|
||||
"--stack-name", stack_name,
|
||||
"--template-file", tmp.name,
|
||||
"--capabilities", "CAPABILITY_IAM",
|
||||
])
|
||||
deployed = True
|
||||
except Exception as e:
|
||||
print(f"deploy failed: {e}", file=sys.stderr)
|
||||
deployed = False
|
||||
return {"template": template, "summary": summary, "deployed": deployed}
|
||||
|
||||
|
||||
def terraform_apply(*, auto_approve: bool = True) -> dict[str, Any]:
|
||||
"""Delegate provisioning to ``terraform apply`` (REQ-369, spec §7.5).
|
||||
|
||||
The operator runs this from the ``nova-platform-ops`` checkout root
|
||||
(where the Terraform modules live). This function shells out to
|
||||
``terraform`` on PATH; the caller (``nova/idp/setup.py``) is
|
||||
responsible for the ``shutil.which("terraform")`` gate.
|
||||
|
||||
Args:
|
||||
auto_approve: pass ``-auto-approve`` (default True; the y/N gate
|
||||
is the operator's PR review in nova-platform-ops).
|
||||
|
||||
Returns:
|
||||
``{"deployed": bool, "returncode": int, "command": [str]}``.
|
||||
"""
|
||||
cmd = ["terraform", "apply"]
|
||||
if auto_approve:
|
||||
cmd.append("-auto-approve")
|
||||
proc = subprocess.run(cmd)
|
||||
return {"deployed": proc.returncode == 0, "returncode": proc.returncode, "command": cmd}
|
||||
|
||||
|
||||
def terraform_plan() -> dict[str, Any]:
|
||||
"""Delegate verification to ``terraform plan`` (REQ-369, spec §7.5).
|
||||
|
||||
Reports the diff between the live stack and the Terraform source in
|
||||
the ``nova-platform-ops`` checkout. The caller is responsible for
|
||||
the ``shutil.which("terraform")`` gate.
|
||||
|
||||
Returns:
|
||||
``{"passed": bool, "returncode": int, "command": [str]}``.
|
||||
"""
|
||||
cmd = ["terraform", "plan"]
|
||||
proc = subprocess.run(cmd)
|
||||
return {"passed": proc.returncode == 0, "returncode": proc.returncode, "command": cmd}
|
||||
|
||||
|
||||
def verify() -> dict[str, Any]:
|
||||
"""Run the KMS round-trip verification (REQ-340 --verify).
|
||||
|
||||
Delegates to the CAP-037 test logic: sign a JWT (mock KMS) → JWKS →
|
||||
pyjwt verify. Returns ``{"passed": bool, "detail": str}``.
|
||||
"""
|
||||
try:
|
||||
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
|
||||
|
||||
priv = ec.generate_private_key(ec.SECP256R1())
|
||||
pub_der = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
class _MockKms:
|
||||
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||
return {"Signature": priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||
def get_public_key(self, KeyId):
|
||||
return {"PublicKey": pub_der}
|
||||
|
||||
kms_signing.set_kms_client_for_testing(_MockKms())
|
||||
token = kms_signing.sign_jwt({"sub": "verify", "exp": 9999999999, "iat": 1, "jti": "v"})
|
||||
jwk = kms_signing.get_jwk()
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False})
|
||||
ok = decoded["sub"] == "verify"
|
||||
return {"passed": ok, "detail": "KMS round-trip OK" if ok else "mismatch"}
|
||||
except Exception as e:
|
||||
return {"passed": False, "detail": f"verify error: {e}"}
|
||||
finally:
|
||||
try:
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "--check"
|
||||
if mode == "--check":
|
||||
print(json.dumps(check_prerequisites(), indent=2))
|
||||
elif mode == "--dry-run":
|
||||
print(json.dumps(generate_and_deploy(dry_run=True)["summary"], indent=2))
|
||||
elif mode == "--verify":
|
||||
print(json.dumps(verify(), indent=2))
|
||||
else:
|
||||
print("usage: nova_idp_setup.py --check|--dry-run|--verify", file=sys.stderr)
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Nova IdP token-vend Lambda — PAT/session → KMS-signed OIDC token
|
||||
(REQ-336, C-6.1/C-7.1 ABAC FAIL-CLOSED, D-229 revocation).
|
||||
|
||||
Accepts a PAT (or session token) and returns a KMS-signed OIDC token
|
||||
with claims ``sub, aud, iss, exp, iat, jti, roles`` (REQ-336).
|
||||
|
||||
## ABAC fail-closed (C-6.1/C-7.1 — INV-17 runtime enforcement)
|
||||
|
||||
The grill's #1 finding: the token-vend Lambda MUST fail closed on ABAC
|
||||
evaluation failure. Concretely, a token is vended **only** when:
|
||||
|
||||
1. The PAT is active (``nova-pats.GetItem(jti, ConsistentRead=True)``
|
||||
returns an item with ``status == "active"`` — D-229; strong read on
|
||||
the main table, GSIs don't support strong reads).
|
||||
2. ``KyvernoJsonEngine.is_configured()`` returns ``True`` **AND**
|
||||
``evaluate_token_vend_policy()`` returns ``allowed=True`` without
|
||||
raising.
|
||||
|
||||
If (2) fails for **any** reason — ``kj`` absent, ``kj`` error, policy
|
||||
parse error, engine raise — the Lambda returns **403** + audit
|
||||
``token.vend.denied`` (reason ``abac_eval_failed``). **Never fail
|
||||
open.** This is verified by ``tests/test_abac_fail_closed.py`` — the
|
||||
most important test of the milestone.
|
||||
|
||||
## Dual-use (REQ-329 pattern)
|
||||
|
||||
Mirrors ``nova_idp_auth.py``: lazy boto3, env-var table names,
|
||||
``NOVA_LAMBDA_LOCAL_BYPASS``, ``__main__`` CLI block, audit emission.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import boto3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config (env-var table names, mirroring nova_idp_auth.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PATS_TABLE = os.environ.get("NOVA_PATS_TABLE", "nova-pats")
|
||||
SESSIONS_TABLE = os.environ.get("NOVA_SESSIONS_TABLE", "nova-sessions")
|
||||
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||
OIDC_ISSUER = os.environ.get("NOVA_OIDC_ISSUER", "nova-idp")
|
||||
OIDC_AUDIENCE = os.environ.get("NOVA_OIDC_AUDIENCE", "nova-cli")
|
||||
# OIDC token lifetime (seconds). Default 15 min.
|
||||
OIDC_TTL_SECONDS = int(os.environ.get("NOVA_OIDC_TTL_SECONDS", "900"))
|
||||
|
||||
_dynamodb = None
|
||||
_kms_client = None
|
||||
|
||||
|
||||
def _get_dynamodb():
|
||||
"""Lazy boto3 DynamoDB resource singleton (mirrors contract_ingestor)."""
|
||||
global _dynamodb
|
||||
if _dynamodb is None:
|
||||
_dynamodb = boto3.resource("dynamodb")
|
||||
return _dynamodb
|
||||
|
||||
|
||||
def _iso8601_now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
|
||||
|
||||
def _epoch_now() -> int:
|
||||
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
|
||||
|
||||
def _emit_audit(event_type: str, **fields) -> None:
|
||||
"""Emit an audit event to stderr as JSON (never the raw PAT/token)."""
|
||||
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||
# Defense-in-depth: scrub raw token fields (INV-16/INV-17 spirit).
|
||||
for _k in ("pat", "session_token", "token", "raw_pat"):
|
||||
payload.pop(_k, None)
|
||||
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PAT / session decoding (decode WITHOUT verifying — signature verified
|
||||
# by KMS public key separately at the JWKS verifier; the revocation
|
||||
# check is the trust anchor here, not the JWT signature).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _decode_jwt_unverified(token: str) -> dict:
|
||||
"""Decode a JWT's payload without verifying the signature."""
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
return pyjwt.decode(token, options={"verify_signature": False})
|
||||
except Exception:
|
||||
# Fallback: manual base64url decode of the payload segment.
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2:
|
||||
raise ValueError("malformed JWT (expected 3 segments)")
|
||||
import base64
|
||||
pad = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(pad))
|
||||
|
||||
|
||||
def _extract_pat_claims(token: str) -> dict:
|
||||
"""Decode a PAT/session JWT → extract jti, sub, typ, roles, owner, exp."""
|
||||
claims = _decode_jwt_unverified(token)
|
||||
required = ("jti", "sub", "exp")
|
||||
for f in required:
|
||||
if f not in claims:
|
||||
raise ValueError(f"token missing claim: {f}")
|
||||
return claims
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revocation check (D-229 — strong read on the main table)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _check_pat_active(jti: str) -> tuple[bool, str]:
|
||||
"""Return ``(active, reason)``. Strong read on nova-pats main table.
|
||||
|
||||
D-229: GSIs don't support strongly-consistent reads, so the
|
||||
revocation check uses ``GetItem(PK=jti, ConsistentRead=True)`` on
|
||||
the main table. This satisfies the 60s SLO synchronously (the
|
||||
strong read reflects the latest write — revocation is instant).
|
||||
"""
|
||||
table = _get_dynamodb().Table(PATS_TABLE)
|
||||
resp = table.get_item(
|
||||
TableName=PATS_TABLE,
|
||||
Key={"jti": jti},
|
||||
ConsistentRead=True,
|
||||
)
|
||||
item = resp.get("Item")
|
||||
if item is None:
|
||||
return False, "pat_unknown"
|
||||
status = item.get("status", "active")
|
||||
if status != "active":
|
||||
return False, f"pat_{status}" # pat_revoked, pat_expired, etc.
|
||||
# Expired? (defense-in-depth; TTL may not have reaped it yet)
|
||||
expires_at = item.get("expires_at")
|
||||
if expires_at is not None:
|
||||
try:
|
||||
if int(expires_at) < _epoch_now():
|
||||
return False, "pat_expired"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return True, "active"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ABAC fail-closed (C-6.1/C-7.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_abac_payload(claims: dict, requested_claims: list[str],
|
||||
target_resource: dict, environment: str,
|
||||
policy_version: str) -> dict:
|
||||
"""Build the ABAC authorization payload (REQ-339, C-5.1)."""
|
||||
return {
|
||||
"subject": {
|
||||
"id": claims.get("sub", ""),
|
||||
"role": (claims.get("roles") or ["unknown"])[0],
|
||||
"owner": claims.get("owner", ""),
|
||||
},
|
||||
"requested_claims": requested_claims,
|
||||
"target_resource": target_resource,
|
||||
"environment": environment,
|
||||
"pat_jti": claims.get("jti", ""),
|
||||
"policy_version": policy_version,
|
||||
}
|
||||
|
||||
|
||||
def _evaluate_abac_fail_closed(payload: dict) -> tuple[bool, list, str, str]:
|
||||
"""Evaluate ABAC with fail-closed semantics (C-6.1).
|
||||
|
||||
Returns ``(allowed, pcrs, policy_sha, reason)``. On ANY failure
|
||||
(engine not configured, evaluate raises, policy parse error) returns
|
||||
``(False, [], "", "abac_eval_failed")``. **Never fails open.**
|
||||
"""
|
||||
# Lazy imports so the module imports without the engine adapter.
|
||||
from core.policy_engine import get_engine
|
||||
|
||||
# C-6.1: is_configured() check. If kj is absent → fail closed.
|
||||
try:
|
||||
engine = get_engine()
|
||||
if not engine.is_configured():
|
||||
_emit_audit(
|
||||
"token.vend.abac_engine_not_configured",
|
||||
pat_jti=payload.get("pat_jti", ""),
|
||||
)
|
||||
return False, [], "", "abac_eval_failed"
|
||||
except Exception: # noqa: BLE001 - fail closed on any engine check error
|
||||
return False, [], "", "abac_eval_failed"
|
||||
|
||||
# C-6.1: evaluate() raising → fail closed.
|
||||
try:
|
||||
from core.abac_evaluator import evaluate_token_vend_policy
|
||||
allowed, pcrs, policy_sha = evaluate_token_vend_policy(payload)
|
||||
reason = "abac_denied" if not allowed else "ok"
|
||||
return allowed, pcrs, policy_sha, reason
|
||||
except Exception: # noqa: BLE001 - fail closed on any eval error
|
||||
return False, [], "", "abac_eval_failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token vend (REQ-336)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_oidc_claims(pat_claims: dict) -> dict:
|
||||
"""Build the OIDC token claims (REQ-336)."""
|
||||
now = _epoch_now()
|
||||
return {
|
||||
"sub": pat_claims["sub"],
|
||||
"aud": OIDC_AUDIENCE,
|
||||
"iss": OIDC_ISSUER,
|
||||
"exp": now + OIDC_TTL_SECONDS,
|
||||
"iat": now,
|
||||
"jti": pat_claims.get("jti", ""), # carry the PAT jti for tracing
|
||||
"roles": pat_claims.get("roles", []),
|
||||
"typ": "nova_oidc_token", # INV-14: distinguish from developer_pat
|
||||
}
|
||||
|
||||
|
||||
def vend_token(
|
||||
token: str,
|
||||
requested_claims: list[str] | None = None,
|
||||
target_resource: dict | None = None,
|
||||
environment: str | None = None,
|
||||
policy_version: str = "",
|
||||
) -> dict:
|
||||
"""Vend a KMS-signed OIDC token for a PAT/session (REQ-336).
|
||||
|
||||
Returns ``{"token": ..., "expires_at": ...}`` on success. Raises
|
||||
``_DeniedError`` (→ 403) on revocation / ABAC denial.
|
||||
"""
|
||||
requested_claims = requested_claims or ["sub", "roles"]
|
||||
environment = environment or "dev"
|
||||
|
||||
# 1. Decode the PAT/session (without verifying — D-229).
|
||||
pat_claims = _extract_pat_claims(token)
|
||||
jti = pat_claims["jti"]
|
||||
|
||||
# Default target_resource: owner inherits from the PAT subject so
|
||||
# the owner-matches ABAC rule passes for same-tenant vends. Callers
|
||||
# can override with an explicit target_resource.
|
||||
if target_resource is None:
|
||||
target_resource = {
|
||||
"type": "contract",
|
||||
"id": "*",
|
||||
"owner": pat_claims.get("owner", "*"),
|
||||
"environment": environment,
|
||||
}
|
||||
|
||||
# 2. Revocation check (D-229, strong read).
|
||||
active, reason = _check_pat_active(jti)
|
||||
if not active:
|
||||
_emit_audit("token.vend.denied", pat_jti=jti, reason=reason)
|
||||
raise _DeniedError(reason)
|
||||
|
||||
# 3. ABAC eval (C-6.1 FAIL-CLOSED).
|
||||
abac_payload = _build_abac_payload(
|
||||
pat_claims, requested_claims, target_resource, environment, policy_version
|
||||
)
|
||||
allowed, _pcrs, policy_sha, abac_reason = _evaluate_abac_fail_closed(abac_payload)
|
||||
if not allowed:
|
||||
_emit_audit(
|
||||
"token.vend.denied",
|
||||
pat_jti=jti,
|
||||
reason=abac_reason,
|
||||
policy_sha=policy_sha,
|
||||
)
|
||||
raise _DeniedError(abac_reason)
|
||||
|
||||
# 4. KMS sign (REQ-337).
|
||||
from core.kms_signing import sign_jwt
|
||||
oidc_claims = _build_oidc_claims(pat_claims)
|
||||
oidc_token = sign_jwt(oidc_claims, key_id=OIDC_KMS_KEY_ID)
|
||||
_emit_audit(
|
||||
"token.vend.allowed",
|
||||
pat_jti=jti,
|
||||
sub=oidc_claims["sub"],
|
||||
policy_sha=policy_sha,
|
||||
expires_at=oidc_claims["exp"],
|
||||
)
|
||||
return {"token": oidc_token, "expires_at": oidc_claims["exp"]}
|
||||
|
||||
|
||||
class _DeniedError(Exception):
|
||||
"""Raised on revocation / ABAC denial → 403."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
self.reason = reason
|
||||
super().__init__(f"token vend denied: {reason}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lambda handler + HTTP mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_http_response(result_or_error):
|
||||
if isinstance(result_or_error, Exception):
|
||||
if isinstance(result_or_error, _DeniedError):
|
||||
return {
|
||||
"statusCode": 403,
|
||||
"body": json.dumps({"error": "token_vend_denied", "reason": result_or_error.reason}),
|
||||
}
|
||||
if isinstance(result_or_error, ValueError):
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"body": json.dumps({"error": str(result_or_error)}),
|
||||
}
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"body": json.dumps({"error": str(result_or_error)}),
|
||||
}
|
||||
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use)."""
|
||||
try:
|
||||
body = event.get("body", "{}")
|
||||
payload = json.loads(body) if isinstance(body, str) else body
|
||||
token = payload.get("token") or payload.get("pat") or payload.get("session_token")
|
||||
if not token:
|
||||
raise ValueError("missing field: token (or pat / session_token)")
|
||||
result = vend_token(
|
||||
token=token,
|
||||
requested_claims=payload.get("requested_claims"),
|
||||
target_resource=payload.get("target_resource"),
|
||||
environment=payload.get("environment"),
|
||||
policy_version=payload.get("policy_version", ""),
|
||||
)
|
||||
return _to_http_response(result)
|
||||
except Exception as e:
|
||||
return _to_http_response(e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI (dual-use, REQ-329 pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cli_main(argv=None):
|
||||
"""CLI entry point for the token-vend Lambda (REQ-329 dual-use)."""
|
||||
raw = argv if argv is not None else sys.argv[1:]
|
||||
local_bypass = os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
|
||||
if not local_bypass:
|
||||
os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
|
||||
try:
|
||||
if "--vend-stdin" in raw:
|
||||
payload = json.loads(sys.stdin.read())
|
||||
elif "--vend" in raw:
|
||||
idx = raw.index("--vend")
|
||||
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||
if not path:
|
||||
print("Usage: --vend <payload.json>", file=sys.stderr)
|
||||
return 2
|
||||
with open(path) as fh:
|
||||
payload = json.loads(fh.read())
|
||||
else:
|
||||
print(
|
||||
"Usage: python3 -m core.lambda.nova_idp_token_vend "
|
||||
"--vend <payload.json> | --vend-stdin < <payload.json>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
token = payload.get("token") or payload.get("pat") or payload.get("session_token")
|
||||
if not token:
|
||||
print("error: missing token in payload", file=sys.stderr)
|
||||
return 1
|
||||
result = vend_token(
|
||||
token=token,
|
||||
requested_claims=payload.get("requested_claims"),
|
||||
target_resource=payload.get("target_resource"),
|
||||
environment=payload.get("environment"),
|
||||
policy_version=payload.get("policy_version", ""),
|
||||
)
|
||||
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||
return 0
|
||||
except _DeniedError as e:
|
||||
sys.stderr.write(f"error: token vend denied ({e.reason})\n")
|
||||
return 3 # 403-class
|
||||
except ValueError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1
|
||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||
sys.stderr.write(f"internal error: {e}\n")
|
||||
return 2
|
||||
finally:
|
||||
if not local_bypass:
|
||||
os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||
sys.exit(cli_main())
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Nova client-mode resolver (P1, REQ-327, D-226).
|
||||
|
||||
Priority: --mode flag → NOVA_CLIENT_MODE env → credential type → TTY.
|
||||
No silent fallbacks: every return carries a non-empty selection_reason.
|
||||
|
||||
INV-13: invalid env values are ignored + warned, then fall through.
|
||||
INV-14: credential_type developer_pat/nova_oidc_token + TTY →
|
||||
interactive; + no-TTY → agent. TTY check is sys.stdin.isatty() (D-226).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
log = logging.getLogger("nova.mode_resolver")
|
||||
|
||||
_VALID_MODES = ("agent", "interactive")
|
||||
_CRED_MODE_TYPES = ("developer_pat", "nova_oidc_token")
|
||||
|
||||
|
||||
def resolve_mode(
|
||||
flag: Optional[str] = None,
|
||||
env_var: Optional[str] = None,
|
||||
credential_type: Optional[str] = None,
|
||||
stdin_isatty: bool = False,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return (mode, selection_reason) honoring D-226 priority."""
|
||||
if flag is not None and flag in _VALID_MODES:
|
||||
return flag, "flag"
|
||||
if env_var is not None and env_var != "":
|
||||
if env_var in _VALID_MODES:
|
||||
return env_var, "env"
|
||||
log.warning(
|
||||
"NOVA_CLIENT_MODE=%r invalid (expected one of %s); ignoring",
|
||||
env_var,
|
||||
_VALID_MODES,
|
||||
)
|
||||
if credential_type in _CRED_MODE_TYPES:
|
||||
mode = "interactive" if stdin_isatty else "agent"
|
||||
return mode, f"credential:{credential_type}"
|
||||
mode = "interactive" if stdin_isatty else "agent"
|
||||
return mode, "tty"
|
||||
|
||||
|
||||
def _read_credential_type(path: Path) -> Optional[str]:
|
||||
"""Read the active credential's type from ~/.nova/credentials.json."""
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
active_jti = data.get("active_credential_jti")
|
||||
for cred in data.get("credentials", []) or []:
|
||||
if cred.get("jti") == active_jti:
|
||||
return cred.get("type")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_mode_from_env(credential_type: Optional[str] = None) -> Tuple[str, str]:
|
||||
"""Resolve mode using sys.argv, NOVA_CLIENT_MODE, credentials, and TTY.
|
||||
|
||||
Best-effort --mode scan of sys.argv (no full argparse); env var;
|
||||
~/.nova/credentials.json active credential type; sys.stdin.isatty().
|
||||
"""
|
||||
flag: Optional[str] = None
|
||||
argv = sys.argv[1:]
|
||||
for i, tok in enumerate(argv):
|
||||
if tok == "--mode" and i + 1 < len(argv):
|
||||
flag = argv[i + 1]
|
||||
break
|
||||
if tok.startswith("--mode="):
|
||||
flag = tok.split("=", 1)[1]
|
||||
break
|
||||
env_var = os.environ.get("NOVA_CLIENT_MODE")
|
||||
if env_var is not None and env_var == "":
|
||||
env_var = ""
|
||||
if credential_type is None:
|
||||
cred_path = Path.home() / ".nova" / "credentials.json"
|
||||
credential_type = _read_credential_type(cred_path)
|
||||
return resolve_mode(
|
||||
flag=flag,
|
||||
env_var=env_var,
|
||||
credential_type=credential_type,
|
||||
stdin_isatty=sys.stdin.isatty(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mode, reason = resolve_mode_from_env()
|
||||
print(f"mode={mode} reason={reason}")
|
||||
@@ -0,0 +1,164 @@
|
||||
"""PAT (personal access token) lifecycle — issue + revoke (REQ-342, REQ-343).
|
||||
|
||||
PATs are signed JWTs (``typ: "developer_pat"``, KMS-signed) that
|
||||
authenticate a developer/service-account to the token-vend Lambda. Only
|
||||
the **hash** is stored in ``nova-pats`` (REQ-343) — the raw PAT is
|
||||
returned to the caller once and never persisted.
|
||||
|
||||
## Max TTL (C-6.2)
|
||||
|
||||
* developer: ≤ 24h (86400s)
|
||||
* service-account: ≤ 1h (3600s)
|
||||
|
||||
Enforced in :func:`issue_pat` via the ``subject_type`` argument.
|
||||
|
||||
## DynamoDB schema (REQ-343)
|
||||
|
||||
* PK: ``jti`` (uuid4)
|
||||
* GSI1: ``sub`` (list PATs for a user)
|
||||
* GSI2: ``pat_hash`` (SHA-256 of the raw PAT for lookup)
|
||||
* ``status``: ``active`` | ``revoked`` (revoked PATs retained for audit)
|
||||
* ``expires_at``: epoch seconds (TTL)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
|
||||
PATS_TABLE = os.environ.get("NOVA_PATS_TABLE", "nova-pats")
|
||||
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||
OIDC_ISSUER = os.environ.get("NOVA_OIDC_ISSUER", "nova-idp")
|
||||
|
||||
# C-6.2 max TTLs (seconds).
|
||||
MAX_TTL_DEV = 24 * 3600 # 24h
|
||||
MAX_TTL_SERVICE = 3600 # 1h
|
||||
|
||||
_dynamodb = None
|
||||
|
||||
|
||||
def _get_dynamodb():
|
||||
global _dynamodb
|
||||
if _dynamodb is None:
|
||||
_dynamodb = boto3.resource("dynamodb")
|
||||
return _dynamodb
|
||||
|
||||
|
||||
def _iso8601_now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
|
||||
|
||||
def _epoch_now() -> int:
|
||||
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
|
||||
|
||||
def _emit_audit(event_type: str, **fields) -> None:
|
||||
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||
for _k in ("pat", "raw_pat"):
|
||||
payload.pop(_k, None)
|
||||
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def _max_ttl(subject_type: str) -> int:
|
||||
if subject_type == "service-account":
|
||||
return MAX_TTL_SERVICE
|
||||
return MAX_TTL_DEV
|
||||
|
||||
|
||||
def issue_pat(
|
||||
subject: str,
|
||||
roles: list[str],
|
||||
owner: str,
|
||||
ttl_seconds: int,
|
||||
key_id: str = OIDC_KMS_KEY_ID,
|
||||
subject_type: str = "developer",
|
||||
claims: dict | None = None,
|
||||
) -> str:
|
||||
"""Issue a PAT (signed JWT) + store its hash in nova-pats (REQ-342).
|
||||
|
||||
Args:
|
||||
subject: the subject (user_id).
|
||||
roles: the roles to embed in the PAT.
|
||||
owner: the tenant owner.
|
||||
ttl_seconds: requested TTL. Clamped to the C-6.2 max for
|
||||
``subject_type`` (24h dev, 1h service-account).
|
||||
key_id: KMS key ID/alias.
|
||||
subject_type: ``"developer"`` or ``"service-account"``.
|
||||
claims: extra claims to embed.
|
||||
|
||||
Returns:
|
||||
The raw PAT JWT string (returned once; only the hash is stored).
|
||||
"""
|
||||
max_ttl = _max_ttl(subject_type)
|
||||
if ttl_seconds > max_ttl:
|
||||
ttl_seconds = max_ttl
|
||||
if ttl_seconds < 1:
|
||||
raise ValueError("ttl_seconds must be >= 1")
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
now = _epoch_now()
|
||||
exp = now + ttl_seconds
|
||||
pat_claims = {
|
||||
"iss": OIDC_ISSUER,
|
||||
"sub": subject,
|
||||
"typ": "developer_pat",
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"exp": exp,
|
||||
"roles": roles,
|
||||
"owner": owner,
|
||||
}
|
||||
if claims:
|
||||
pat_claims.update(claims)
|
||||
|
||||
from core.kms_signing import sign_jwt
|
||||
pat_jwt = sign_jwt(pat_claims, key_id=key_id)
|
||||
|
||||
# Only the hash is stored (REQ-343) — NOT the raw PAT.
|
||||
pat_hash = hashlib.sha256(pat_jwt.encode("ascii")).hexdigest()
|
||||
table = _get_dynamodb().Table(PATS_TABLE)
|
||||
table.put_item(
|
||||
TableName=PATS_TABLE,
|
||||
Item={
|
||||
"jti": jti,
|
||||
"sub": subject,
|
||||
"pat_hash": pat_hash,
|
||||
"status": "active",
|
||||
"issued_at": _iso8601_now(),
|
||||
"expires_at": str(exp),
|
||||
"subject_type": subject_type,
|
||||
"claims": json.dumps(pat_claims),
|
||||
},
|
||||
)
|
||||
_emit_audit("pat.issued", jti=jti, sub=subject, subject_type=subject_type, ttl=ttl_seconds)
|
||||
return pat_jwt
|
||||
|
||||
|
||||
def revoke_pat(jti: str) -> dict:
|
||||
"""Revoke a PAT (D-229, REQ-342). Revoked PATs retained for audit.
|
||||
|
||||
Returns the update response. Audit ``pat.revoked`` emitted.
|
||||
"""
|
||||
table = _get_dynamodb().Table(PATS_TABLE)
|
||||
resp = table.update_item(
|
||||
TableName=PATS_TABLE,
|
||||
Key={"jti": jti},
|
||||
UpdateExpression="SET #s = :rev, revoked_at = :now",
|
||||
ExpressionAttributeNames={"#s": "status"},
|
||||
ExpressionAttributeValues={":rev": "revoked", ":now": _iso8601_now()},
|
||||
)
|
||||
_emit_audit("pat.revoked", jti=jti)
|
||||
return resp
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
print("use nova/auth/login.py and nova/auth/revoke.py", file=sys.stderr)
|
||||
@@ -0,0 +1,551 @@
|
||||
# Archived: Nova IdP CloudFormation Template (v1.28)
|
||||
|
||||
> **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 (REQ-369 AC 3, spec §7.5). This template is read-only
|
||||
> reference; do not modify it. The `nova idp setup --apply` command
|
||||
> now delegates to `terraform apply` (see `nova/idp/setup.py`).
|
||||
|
||||
This is the verbatim output of `generate_template()` from
|
||||
`core/lambda/nova_idp_cfn.py` (the composition of the DynamoDB snippet
|
||||
from `core/lambda/nova_idp_auth_cfn.py` + the KMS signing key + the
|
||||
three IdP Lambdas + their IAM roles + function URLs). It was the active
|
||||
provisioning path through v1.28; from v1.29 the operator runs
|
||||
`terraform apply` in the `nova-platform-ops` checkout and `nova idp
|
||||
setup --apply` delegates to it. The CFN generation code is retained as
|
||||
read-only reference and emits a `DeprecationWarning` when the CFN
|
||||
fallback path is invoked (terraform absent from PATH).
|
||||
|
||||
```json
|
||||
{
|
||||
"Resources": {
|
||||
"NovaUsersTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-users",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"PointInTimeRecoverySpecification": {
|
||||
"PointInTimeRecoveryEnabled": true
|
||||
},
|
||||
"AttributeShape": {
|
||||
"user_id": "String",
|
||||
"email": "String",
|
||||
"password_hash": "String",
|
||||
"owner": "String",
|
||||
"roles": "List",
|
||||
"created_at": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaSessionsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-sessions",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": true
|
||||
},
|
||||
"AttributeShape": {
|
||||
"session_id": "String",
|
||||
"user_id": "String",
|
||||
"expires_at": "String (epoch seconds, TTL)",
|
||||
"created_at": "String (ISO-8601)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaPasswordResetsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-password-resets",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
{
|
||||
"AttributeName": "reset_token",
|
||||
"KeyType": "HASH"
|
||||
}
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
{
|
||||
"AttributeName": "reset_token",
|
||||
"AttributeType": "S"
|
||||
}
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": true
|
||||
},
|
||||
"AttributeShape": {
|
||||
"reset_token": "String",
|
||||
"user_id": "String",
|
||||
"expires_at": "String (epoch seconds, TTL; 15 min)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaPatsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-pats",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": true
|
||||
},
|
||||
"AttributeShape": {
|
||||
"jti": "String (PK)",
|
||||
"sub": "String (GSI1; subject / user_id)",
|
||||
"pat_hash": "String (GSI2; SHA-256 of the PAT for lookup)",
|
||||
"status": "String (active|revoked)",
|
||||
"issued_at": "String (ISO-8601)",
|
||||
"expires_at": "String (epoch seconds, TTL)",
|
||||
"revoked_at": "String (ISO-8601, present iff status=revoked)",
|
||||
"claims": "Map (JWT claims payload)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaOidcSigningKey": {
|
||||
"Type": "AWS::KMS::Key",
|
||||
"Properties": {
|
||||
"Description": "Nova OIDC token signing key (REQ-337, ECC_NIST_P256)",
|
||||
"KeySpec": "ECC_NIST_P256",
|
||||
"KeyUsage": "SIGN_VERIFY",
|
||||
"KeyPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": {
|
||||
"Fn::Sub": "arn:aws:iam::${AWS::AccountId}:root"
|
||||
}
|
||||
},
|
||||
"Action": "kms:*",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaOidcSigningKeyAlias": {
|
||||
"Type": "AWS::KMS::Alias",
|
||||
"Properties": {
|
||||
"AliasName": "alias/nova-oidc-signing",
|
||||
"TargetKeyId": {
|
||||
"Fn::GetAtt": "NovaOidcSigningKey.Arn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaIdpAuthRole": {
|
||||
"Type": "AWS::IAM::Role",
|
||||
"Properties": {
|
||||
"AssumeRolePolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Service": {
|
||||
"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"
|
||||
}
|
||||
},
|
||||
"Action": "sts:AssumeRole"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Policies": [
|
||||
{
|
||||
"PolicyName": "NovaIdpAuthPolicy",
|
||||
"PolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogGroup"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"dynamodb:GetItem",
|
||||
"dynamodb:PutItem",
|
||||
"dynamodb:UpdateItem",
|
||||
"dynamodb:Query",
|
||||
"dynamodb:DeleteItem"
|
||||
],
|
||||
"Resource": [
|
||||
{
|
||||
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-users"
|
||||
},
|
||||
{
|
||||
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-sessions"
|
||||
},
|
||||
{
|
||||
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-password-resets"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"NovaIdpTokenVendRole": {
|
||||
"Type": "AWS::IAM::Role",
|
||||
"Properties": {
|
||||
"AssumeRolePolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Service": {
|
||||
"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"
|
||||
}
|
||||
},
|
||||
"Action": "sts:AssumeRole"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Policies": [
|
||||
{
|
||||
"PolicyName": "NovaIdpTokenVendPolicy",
|
||||
"PolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogGroup"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"dynamodb:GetItem",
|
||||
"dynamodb:PutItem",
|
||||
"dynamodb:UpdateItem",
|
||||
"dynamodb:Query",
|
||||
"dynamodb:DeleteItem"
|
||||
],
|
||||
"Resource": [
|
||||
{
|
||||
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-pats"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:Sign",
|
||||
"kms:GetPublicKey",
|
||||
"kms:DescribeKey"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::GetAtt": "NovaOidcSigningKey.Arn"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"NovaIdpJwksRole": {
|
||||
"Type": "AWS::IAM::Role",
|
||||
"Properties": {
|
||||
"AssumeRolePolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Service": {
|
||||
"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"
|
||||
}
|
||||
},
|
||||
"Action": "sts:AssumeRole"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Policies": [
|
||||
{
|
||||
"PolicyName": "NovaIdpJwksPolicy",
|
||||
"PolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogGroup"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:Sign",
|
||||
"kms:GetPublicKey",
|
||||
"kms:DescribeKey"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::GetAtt": "NovaOidcSigningKey.Arn"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"NovaIdpAuthFunction": {
|
||||
"Type": "AWS::Lambda::Function",
|
||||
"Properties": {
|
||||
"Handler": "nova_idp_auth.lambda_handler",
|
||||
"Runtime": "python3.12",
|
||||
"MemorySize": 512,
|
||||
"Timeout": 30,
|
||||
"Role": {
|
||||
"Fn::GetAtt": [
|
||||
"NovaIdpAuthRole",
|
||||
"Arn"
|
||||
]
|
||||
},
|
||||
"Environment": {
|
||||
"Variables": {
|
||||
"NOVA_USERS_TABLE": "nova-users",
|
||||
"NOVA_SESSIONS_TABLE": "nova-sessions",
|
||||
"NOVA_PASSWORD_RESETS_TABLE": "nova-password-resets",
|
||||
"NOVA_PATS_TABLE": "nova-pats"
|
||||
}
|
||||
},
|
||||
"Code": {
|
||||
"ZipFile": "def lambda_handler(event, context):\n return {}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaIdpTokenVendFunction": {
|
||||
"Type": "AWS::Lambda::Function",
|
||||
"Properties": {
|
||||
"Handler": "nova_idp_token_vend.lambda_handler",
|
||||
"Runtime": "python3.12",
|
||||
"MemorySize": 512,
|
||||
"Timeout": 30,
|
||||
"Role": {
|
||||
"Fn::GetAtt": [
|
||||
"NovaIdpTokenVendRole",
|
||||
"Arn"
|
||||
]
|
||||
},
|
||||
"Environment": {
|
||||
"Variables": {
|
||||
"NOVA_USERS_TABLE": "nova-users",
|
||||
"NOVA_SESSIONS_TABLE": "nova-sessions",
|
||||
"NOVA_PASSWORD_RESETS_TABLE": "nova-password-resets",
|
||||
"NOVA_PATS_TABLE": "nova-pats",
|
||||
"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"
|
||||
}
|
||||
},
|
||||
"Code": {
|
||||
"ZipFile": "def lambda_handler(event, context):\n return {}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaIdpJwksFunction": {
|
||||
"Type": "AWS::Lambda::Function",
|
||||
"Properties": {
|
||||
"Handler": "nova_idp_jwks.lambda_handler",
|
||||
"Runtime": "python3.12",
|
||||
"MemorySize": 256,
|
||||
"Timeout": 30,
|
||||
"Role": {
|
||||
"Fn::GetAtt": [
|
||||
"NovaIdpJwksRole",
|
||||
"Arn"
|
||||
]
|
||||
},
|
||||
"Environment": {
|
||||
"Variables": {
|
||||
"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"
|
||||
}
|
||||
},
|
||||
"Code": {
|
||||
"ZipFile": "def lambda_handler(event, context):\n return {}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaIdpAuthUrl": {
|
||||
"Type": "AWS::Lambda::Url",
|
||||
"Properties": {
|
||||
"TargetFunction": {
|
||||
"Ref": "NovaIdpAuthFunction"
|
||||
},
|
||||
"AuthType": "AWS_IAM"
|
||||
}
|
||||
},
|
||||
"NovaIdpTokenVendUrl": {
|
||||
"Type": "AWS::Lambda::Url",
|
||||
"Properties": {
|
||||
"TargetFunction": {
|
||||
"Ref": "NovaIdpTokenVendFunction"
|
||||
},
|
||||
"AuthType": "AWS_IAM"
|
||||
}
|
||||
},
|
||||
"NovaIdpJwksUrl": {
|
||||
"Type": "AWS::Lambda::Url",
|
||||
"Properties": {
|
||||
"TargetFunction": {
|
||||
"Ref": "NovaIdpJwksFunction"
|
||||
},
|
||||
"AuthType": "NONE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,123 @@
|
||||
# CodeArtifact Provisioning — Status + Fallback (REQ-323, CAP-035)
|
||||
|
||||
> Phase P1 (cli-substrate), milestone v1.28. Owner: backend-engineer.
|
||||
> This document records the CodeArtifact provisioning check outcome for
|
||||
> the `nova-cli` wheel + Lambda layer publish pipeline (REQ-323), the
|
||||
> required IAM grants, and the fallback wheel-index mode the publish
|
||||
> workflow supports when CodeArtifact is not yet provisioned.
|
||||
|
||||
## 1. Provisioning check (best-effort, P1 Wave 4 gate)
|
||||
|
||||
**Target account:** `581513795199` (the Nova platform account).
|
||||
|
||||
**Attempted commands:**
|
||||
|
||||
```bash
|
||||
aws codeartifact list-domains --region us-east-1
|
||||
aws codeartifact describe-repository --domain nova --repository nova-pypi --region us-east-1
|
||||
aws codeartifact list-repositories --domain nova --region us-east-1
|
||||
```
|
||||
|
||||
**Result:** the check could not complete — no AWS credentials were
|
||||
available in the P1 execute environment (`Unable to locate credentials.
|
||||
You can configure credentials by running `aws configure`.`). This is
|
||||
the "fail gracefully" path documented in the task spec: provisioning is
|
||||
**not attempted** from this environment because the required IAM grants
|
||||
are not confirmed for the execute principal.
|
||||
|
||||
**Classification:** P1 blocker for the CodeArtifact mode of the publish
|
||||
workflow's wheel-upload step. The workflow ships with a fallback mode
|
||||
(see §3) so the pipeline is not blocked on CodeArtifact provisioning —
|
||||
it can publish to a private wheel index instead.
|
||||
|
||||
## 2. Required IAM grants (for a follow-up provisioning task)
|
||||
|
||||
To provision + use CodeArtifact as the wheel index, the principal that
|
||||
runs the publish workflow (OIDC role `nova-publish-*` or the spike
|
||||
runner) needs the following grants in account `581513795199`:
|
||||
|
||||
| Action | Scope (example) | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `codeartifact:CreateDomain` | `arn:aws:codeartifact:us-east-1:581513795199:domain/nova` | create the `nova` domain |
|
||||
| `codeartifact:CreateRepository` | `arn:aws:codeartifact:us-east-1:581513795199:repository/nova/*` | create `nova-pypi` (pypi-format) |
|
||||
| `codeartifact:GetRepositoryEndpoint` | `arn:aws:codeartifact:us-east-1:581513795199:repository/nova/nova-pypi` | get the twine/pip endpoint |
|
||||
| `codeartifact:GetAuthorizationToken` | `arn:aws:codeartifact:us-east-1:581513795199:domain/nova/*` | mint short-lived upload token |
|
||||
| `codeartifact:ReadFromRepository` | `arn:aws:codeartifact:us-east-1:581513795199:repository/nova/nova-pypi` | pip install (consumers + the composite action) |
|
||||
| `codeartifact:PublishPackageToRepository` | `arn:aws:codeartifact:us-east-1:581513795199:repository/nova/nova-pypi` | twine upload |
|
||||
| `ssm:PutParameter` / `ssm:GetParameter` | `arn:aws:ssm:us-east-1:581513795199:parameter/nova/layer/*` | CAP-035 version↔ARN mapping |
|
||||
| `lambda:PublishLayerVersion` | `arn:aws:lambda:us-east-1:581513795199:layer:nova-cli` | Lambda layer publish |
|
||||
| `iam:CreateRole` / `iam:PassRole` (already held) | — | only if a dedicated publish OIDC role must be created |
|
||||
|
||||
The domain + repository to provision:
|
||||
|
||||
- **Domain:** `nova`
|
||||
- **Repository:** `nova-pypi` (format: `pypi`)
|
||||
- **Endpoint (twine/pip):**
|
||||
`https://nova-581513795199.d.codeartifact.us-east-1.amazonaws.com/pypi/nova-pypi/`
|
||||
|
||||
Once provisioned, set the repository secret `NOVA_CODEARTIFACT_DOMAIN=nova`
|
||||
on both forges and the publish workflow + composite action will switch
|
||||
to CodeArtifact mode automatically (see §3).
|
||||
|
||||
## 3. Fallback: private wheel index (`NOVA_WHEEL_INDEX`)
|
||||
|
||||
Both the publish workflow (`.github/workflows/publish.yml` and its
|
||||
byte-identical mirror on the dev forge) and the composite action
|
||||
(`.github/actions/nova-cli/action.yml`) support a **fallback mode** that
|
||||
does not require CodeArtifact. The selection is env/secret driven:
|
||||
|
||||
| Mode | Trigger | Upload target | Install source |
|
||||
| --- | --- | --- | --- |
|
||||
| **CodeArtifact** | `NOVA_CODEARTIFACT_DOMAIN` env/secret is set | `aws codeartifact login --tool twine` → twine uploads to the CodeArtifact pypi endpoint | `aws codeartifact login --tool pip` → `pip install nova==<ver>` |
|
||||
| **Fallback index** | `NOVA_CODEARTIFACT_DOMAIN` unset; `TWINE_REPOSITORY_URL` + `TWINE_USERNAME` + `TWINE_PASSWORD` set | `twine upload` to `TWINE_REPOSITORY_URL` | `pip install --index-url $NOVA_WHEEL_INDEX nova==<ver>` |
|
||||
|
||||
The fallback index can be any PEP 503-compliant simple index — e.g. a
|
||||
private package registry hosted on the dev forge, a self-hosted
|
||||
`pypiserver`, or a static S3-backed index. The workflow does not hardcode
|
||||
the index URL; it is supplied via the `NOVA_WHEEL_INDEX` env var (for
|
||||
consumers / the composite action) and `TWINE_REPOSITORY_URL` (for the
|
||||
publish step). This keeps the forge/registry choice deployment-specific
|
||||
and avoids baking any single hostname into the synced workflow files.
|
||||
|
||||
### 3.1 Fallback index shape (when self-hosted)
|
||||
|
||||
A minimal PEP 503 simple index served from a private registry is
|
||||
sufficient. The only required layout per package:
|
||||
|
||||
```
|
||||
/nova/
|
||||
index.html # links to each version's page
|
||||
/nova-<version>-py3-none-any.whl # the wheel (publish workflow uploads this)
|
||||
```
|
||||
|
||||
The publish workflow uploads `dist/nova-<version>-*.whl` via `twine
|
||||
upload` to `TWINE_REPOSITORY_URL`; consumers install via
|
||||
`pip install --index-url "$NOVA_WHEEL_INDEX" nova==<version>`.
|
||||
|
||||
## 4. CAP-035 invariant (unaffected by the index choice)
|
||||
|
||||
Regardless of which wheel index is used, the Lambda layer ARN ↔ wheel
|
||||
version mapping is recorded in SSM and is the source of truth for
|
||||
CAP-035:
|
||||
|
||||
```
|
||||
/nova/layer/nova-cli/version = "<wheel-version>:<layer-arn>"
|
||||
```
|
||||
|
||||
e.g. `1.14.0:arn:aws:lambda:us-east-1:581513795199:layer:nova-cli:3`.
|
||||
The publish workflow writes this parameter atomically after both the
|
||||
wheel upload and the layer publish succeed; if either fails the job
|
||||
fails (merge blocked, REQ-323 AC).
|
||||
|
||||
## 5. Open follow-ups
|
||||
|
||||
1. Provision CodeArtifact domain `nova` + repository `nova-pypi` in
|
||||
`581513795199` once the `codeartifact:*` grants in §2 are attached to
|
||||
the publish OIDC role. Update this document with the confirmed ARN +
|
||||
endpoint.
|
||||
2. Set the `NOVA_CODEARTIFACT_DOMAIN` repository secret on both forges
|
||||
to switch the publish workflow + composite action from fallback-index
|
||||
mode to CodeArtifact mode.
|
||||
3. Until §1 is done, the fallback index must be provisioned out of band
|
||||
and its URL exposed to consumers via the `NOVA_WHEEL_INDEX` env var
|
||||
(and to the publish workflow via the `TWINE_*` secrets).
|
||||
@@ -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,51 @@
|
||||
# kyverno-json (`kj`) Lambda layer
|
||||
|
||||
This document records how the `kj` (kyverno-json) binary is pinned and
|
||||
bundled into the Nova token-vend Lambda layer (D-227, C-8.2).
|
||||
|
||||
## Pin (C-8.2)
|
||||
|
||||
The `kj` binary is pinned to a specific release. The version + SHA256
|
||||
of the binary used for local ABAC tests and bundled into the Lambda
|
||||
layer are recorded in [`platform/abac/kj-version.txt`](../platform/abac/kj-version.txt):
|
||||
|
||||
```
|
||||
<version>
|
||||
<sha256>
|
||||
```
|
||||
|
||||
**Current pin:** `v0.0.3` —
|
||||
`4ebb9a19fbf545e17f046c137f9b69c4288d021e5c73d962835671e0cb3fbf07`
|
||||
(measured from `/usr/local/bin/kj` on the build host).
|
||||
|
||||
C-8.2 requires pinning to a specific release (not `latest`) and
|
||||
recording the SHA256 so a supply-chain compromise of the upstream
|
||||
release is detectable. The build step downloads the pinned release,
|
||||
verifies the SHA256 against the recorded value, and aborts on mismatch.
|
||||
|
||||
## Lambda layer bundling
|
||||
|
||||
The publish workflow (P1, `.github/workflows/`) bundles the pinned `kj`
|
||||
Linux amd64 binary into the `nova-cli` Lambda layer at `layer/bin/kj`.
|
||||
At runtime the Lambda mounts the layer at `/opt`, so `kj` is on PATH at
|
||||
`/opt/bin/kj`. `KyvernoJsonEngine.is_configured()` checks `which kj` →
|
||||
`/opt/bin/kj` and returns `False` when absent — the token-vend Lambda
|
||||
then **fails closed** (C-6.1, 403 `abac_eval_failed`), it never vends a
|
||||
token without an ABAC decision.
|
||||
|
||||
## Local testing
|
||||
|
||||
`/usr/local/bin/kj` exists on the build host. The local ABAC tests
|
||||
(`tests/test_abac_policy.py`, `tests/test_abac_fail_closed.py`) use the
|
||||
real `kj` binary — they are skipped (not failed) when `kj` is absent.
|
||||
|
||||
## Fallback / migration path (D-227)
|
||||
|
||||
If the `kj` Go binary proves unsuitable for the Lambda runtime (e.g. a
|
||||
future release exceeds the 250 MB layer unzip limit or drops AL2023
|
||||
compatibility), the migration path is to run kyverno-json on AWS
|
||||
Fargate behind an internal NLB and have the token-vend Lambda call it
|
||||
over HTTP. The `PolicyEngine` Protocol (`core/policy_engine.py`) is the
|
||||
swap boundary — a `KyvernoJsonHttpEngine` would implement the same
|
||||
protocol without touching the token-vend Lambda's ABAC fail-closed
|
||||
logic. This is a documented fallback, not the v1.28 default.
|
||||
@@ -0,0 +1,53 @@
|
||||
# KMS asymmetric key provisioning (C-1.1)
|
||||
|
||||
This document records the C-1.1 verification for the Nova OIDC signing
|
||||
KMS key and the provisioning path used by `nova idp setup`.
|
||||
|
||||
## C-1.1 verification (P4)
|
||||
|
||||
C-1.1 requires verifying KMS asymmetric key support **before**
|
||||
implementation. The verification command is:
|
||||
|
||||
```
|
||||
aws kms create-key \
|
||||
--key-spec ECC_NIST_P256 \
|
||||
--key-usage SIGN_VERIFY \
|
||||
--description nova-oidc-signing
|
||||
```
|
||||
|
||||
**Result on the P4 build host:** AWS credentials are not available
|
||||
(`Unable to locate credentials`), so the live verification could not
|
||||
run. This is recorded as a **P4 CI gate**: the `nova idp setup --check`
|
||||
command (Wave 8) performs this verification when AWS creds are present
|
||||
and reports it as a missing prerequisite when they are not. The code
|
||||
proceeds against the documented KMS API (REQ-337); tests use a test
|
||||
ECDSA P-256 keypair + mocked `boto3.client("kms")` (no real AWS calls).
|
||||
|
||||
KMS asymmetric signing keys (`ECC_NIST_P256` + `SIGN_VERIFY`) are GA
|
||||
in all commercial regions (announced 2020-11). The
|
||||
`ECDSA_SHA_256` signing algorithm is supported. Confidence: high.
|
||||
|
||||
## Key spec (REQ-337)
|
||||
|
||||
* **Key spec:** `ECC_NIST_P256` (NIST P-256 / secp256r1)
|
||||
* **Key usage:** `SIGN_VERIFY`
|
||||
* **Signing algorithm:** `ECDSA_SHA_256` (JWS `ES256`)
|
||||
* **Alias:** `alias/nova-oidc-signing`
|
||||
* **Rotation:** manual, 90 days (matches D-069 CMK cadence). New key +
|
||||
re-point alias + JWKS serves both `kid`s during overlap.
|
||||
|
||||
## DER → raw ECDSA conversion (the #1 gotcha)
|
||||
|
||||
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. The conversion (in
|
||||
`core/kms_signing.py:der_to_raw_ecdsa`):
|
||||
|
||||
```python
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
||||
r, s = decode_dss_signature(der_sig)
|
||||
raw = r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
||||
```
|
||||
|
||||
This is verified by `tests/test_kms_signing.py` and the CAP-037
|
||||
round-trip test (`tests/test_kms_roundtrip.py`).
|
||||
@@ -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,747 @@
|
||||
# Operator Guide — Nova Platform Ops (`nova-platform-ops`)
|
||||
|
||||
> **REQ-OPS-GUIDE** — the operator-facing runbook for the
|
||||
> `nova-platform-ops` Terraform repo. This is the verification surface
|
||||
> for the covered-reference REQs (355-366, 371): their cutover gates
|
||||
> (M1/M1.5/M2) are documented in §18 below, and each REQ has a
|
||||
> **"Result" column** that the operator fills in after running the gate.
|
||||
> P6 audit verifies every covered-reference REQ has a non-empty, green
|
||||
> Result (grill CF-2/G-5). **HARD P6 ship gate:** §3 contains the
|
||||
> operator-attested "M1.5 Verification Gate Result" row (grill
|
||||
> CF-1/G-2.1) — the milestone does not ship until that row is filled.
|
||||
>
|
||||
> Audience: platform operators / SREs running the live Nova platform in
|
||||
> AWS account `581513795199`. For the developer auth flows, see
|
||||
> `docs/developer-guide-auth.md`; for the legacy CloudFormation path,
|
||||
> see `docs/archive/nova-idp-cfn-v1.28.md`.
|
||||
|
||||
## 1. Overview + reposplit rationale
|
||||
|
||||
Nova's platform operations live in a dedicated, Gitea-private Terraform
|
||||
repository — `nova-platform-ops` — separate from the engineering repo
|
||||
`acdl/acdl`. The split is grounded in Vision §4 (Domain Boundaries):
|
||||
|
||||
> *The platform begins where the artifact is compiled and ends where it
|
||||
> runs in production under operational guardrails.*
|
||||
|
||||
That is two distinct disciplines with two distinct ownership surfaces:
|
||||
|
||||
| Discipline | Ends | Begins | Repo | Surface |
|
||||
|------------|------|--------|------|---------|
|
||||
| Engineering | at the compiled artifact | — | `acdl/acdl` (GitHub) | `publish.yml` + GitHub Releases |
|
||||
| Operations | — | at the live platform under guardrails | `nova-platform-ops` (Gitea-private) | Terraform modules |
|
||||
|
||||
**Scope split (CLARIFY Q-P1, D-232):**
|
||||
|
||||
- `acdl/acdl` authors `publish.yml` (the artifact publish pipeline) +
|
||||
the artifacts themselves (Lambda zip, layer wheel, Python wheel, ECR
|
||||
container image with the static `kj` binary). Each tag `v1.29.x`
|
||||
produces a GitHub Release with SHA-256-verified artifacts (REQ-354).
|
||||
- `nova-platform-ops` authors the Terraform modules
|
||||
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`)
|
||||
that bring those artifacts live in `581513795199`.
|
||||
|
||||
The handoff between the two repos is the **tag-pin** (D-235):
|
||||
`nova-platform-ops` declares `local.nova_platform_version` +
|
||||
`local.kj_source_sha` and resolves substrates through a single
|
||||
`data.aws_ecr_image.kj_image`. The engineering repo never knows which
|
||||
tag is live; the ops repo never authors artifacts. Vision §6
|
||||
immutability + Vision §5 narrow interfaces.
|
||||
|
||||
The covered-reference REQs (355-366, 371) are authored in
|
||||
`nova-platform-ops` (out-of-band). CIAgent in `acdl` tracks them for
|
||||
milestone completeness; their verification surface is the cutover
|
||||
gates in §18 of this guide.
|
||||
|
||||
## 2. Day-0 cutover procedure (M1)
|
||||
|
||||
The M1 cutover is the one-time conversion of the live AWS account
|
||||
`581513795199` from CloudFormation-managed (or manually-created)
|
||||
resources to Terraform-managed resources in `nova-platform-ops`. It is
|
||||
conditional on the M1.5 verification gate passing (§3, Q7 carry-forward,
|
||||
D-236).
|
||||
|
||||
The 10-step Journey 2 (spec §3.2):
|
||||
|
||||
1. **Create `nova-platform-ops` in Gitea** — private (`private: true`,
|
||||
OPER-PRIV, REQ-359), no GitHub mirror. The repo is operator-owned;
|
||||
CIAgent has no presence there.
|
||||
|
||||
2. **Commit the initial Terraform structure** — the module tree
|
||||
(`networking`/`kms`/`identity`/`contract-ingest`/`bootstrap`/`edge`)
|
||||
+ `importable-resources.tf` (§12) + `versions.tf` + `backend.tf`
|
||||
(S3 state in the imported bucket).
|
||||
|
||||
3. **`terraform init`** — initialize the S3 backend against the
|
||||
state bucket (`nova-tfstate-581513795199-us-east-1`, imported in
|
||||
step 5). The bucket is created manually once (operator's secure
|
||||
scratch, spec §7.1, D-235) before Terraform adopts it.
|
||||
|
||||
4. **`terraform import` for existing live resources** — adopt the
|
||||
resources that already exist in `581513795199` into Terraform state
|
||||
without recreating them. The import map is in
|
||||
`importable-resources.tf` (§12):
|
||||
- `aws_s3_bucket.nova_tfstate` ← `nova-tfstate-581513795199-us-east-1`
|
||||
- `aws_dynamodb_table.nova_contracts` ← `nova-contracts`
|
||||
- `aws_dynamodb_table.nova_change_requests` ← `nova-change-requests`
|
||||
- `aws_dynamodb_table.nova_outbox` ← `nova-outbox`
|
||||
- `aws_iam_role.acdl_act_runner` ← `acdl-act-runner-role`
|
||||
- per-stack CMKs (KMS keys)
|
||||
|
||||
Re-import exits non-zero with `resource_already_imported`
|
||||
(IMPORT-IDEMPOTENT, REQ-361). CI import treats this as idempotent
|
||||
success — the import workflow greps the error stream + exits 0 on
|
||||
that string.
|
||||
|
||||
5. **(state bucket is imported in step 4)** — listed here for sequence
|
||||
clarity; the S3 state bucket is the first import because the
|
||||
backend depends on it.
|
||||
|
||||
6. **Add new resources** that do not yet exist in the account:
|
||||
- KMS alias `alias/nova-oidc-signing` (§9, D-234).
|
||||
- Identity DynamoDB tables: `nova-users`, `nova-sessions`,
|
||||
`nova-pats` (§11).
|
||||
- JWKS Function URL with `AuthType: AWS_IAM` (NOT `NONE` — §10,
|
||||
INV-18, RESEARCH §4 critical pitfall).
|
||||
- CloudFront distribution + OAC + WAF WebACL + ACM certificate +
|
||||
Route53 alias (§14, REQ-364/365/366).
|
||||
|
||||
7. **`terraform plan`** — expect zero diff on the imported resources
|
||||
(they are already in their desired state) + a pure-add diff on the
|
||||
new resources. If the plan shows a diff on an imported resource,
|
||||
the import map or the Terraform resource block is wrong — fix
|
||||
before apply. **DRIFT-DETECT (REQ-356):** `terraform plan` exit 2
|
||||
(drift) fails the apply workflow; manual reconciliation required.
|
||||
|
||||
8. **HITL approval** — `terraform apply` against `main` requires a
|
||||
Gitea Actions approval from a user **distinct from the PR author**
|
||||
(TFM-HITL, REQ-357, INV-3). Self-approval is rejected:
|
||||
`gitea.triggering_actor == pull_request.user.login` → apply fails
|
||||
closed (M1.5 item 11).
|
||||
|
||||
9. **`terraform apply`** — on approval, the apply creates the new
|
||||
resources + adopts the imported ones. Smoke test (step 10) before
|
||||
declaring M1 done.
|
||||
|
||||
10. **Smoke test + CFN→Terraform conversion** — verify the live
|
||||
account is in the desired state (JWKS reachable via CloudFront,
|
||||
KMS round-trip, ABAC fail-closed). The CFN template in
|
||||
`acdl/acdl/nova/idp/setup.py` is archived to
|
||||
`docs/archive/nova-idp-cfn-v1.28.md` as read-only reference
|
||||
(REQ-369); the active path is now `terraform apply` in
|
||||
`nova-platform-ops`.
|
||||
|
||||
## 3. M1.5 verification gate (12-item spike)
|
||||
|
||||
The M1.5 gate is the 12-item spike from PLAN.md "Happy Path" (spec
|
||||
§3.3 Edge 5 items 1-8 + grill CF-1 items 9-12). **3 consecutive green
|
||||
rebuilds are required** in `nova-platform-ops` CI.
|
||||
|
||||
The 12 items:
|
||||
|
||||
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
|
||||
`alias/nova-oidc-signing` (REQ-362 path — covered-reference).
|
||||
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 fail-closed).
|
||||
8. Image URI is recorded in Terraform state and in this operator
|
||||
guide (§18, REQ-371 Result row).
|
||||
9. **(grill CF-1) JWKS-EDGE-ONLY:** direct JWKS Function URL GET
|
||||
(bypassing CloudFront) returns **403**; via-CloudFront GET returns
|
||||
**200** (INV-18). Proves `AuthType: AWS_IAM` + OAC pinning.
|
||||
10. **(grill CF-1) IAM-NARROW:** `aws iam get-role-policy` on the
|
||||
Gitea OIDC role asserts no `Action: "*"` and no `Resource: "*"`
|
||||
(REQ-360).
|
||||
11. **(grill CF-1) TFM-HITL:** a `terraform apply`
|
||||
`workflow_dispatch` triggered by the PR author is **rejected**
|
||||
(exit non-zero); a dispatch by a distinct user proceeds (REQ-357).
|
||||
12. **(grill CF-1) rollback drill:** revert `nova_platform_version`
|
||||
pin → `terraform apply` → assert the prior ECR digest runs
|
||||
(D-236, guards against ECR tag mutability).
|
||||
|
||||
If items 1-7 fail three consecutive rebuilds, M2a activates (§5,
|
||||
REQ-363b Fargate toggle) with the same ECR image — no warmup hit
|
||||
because the standby is always running the same digest (KJ-LOCKSTEP).
|
||||
|
||||
### HARD P6 ship gate (grill CF-1/G-2.1)
|
||||
|
||||
P6 must not ship `v1.28.6` until the operator attests the M1.5 result
|
||||
in the row below. The operator fills this in **after** the gate passes
|
||||
3 consecutive green rebuilds in `nova-platform-ops` CI. P6 audit
|
||||
verifies the row exists + is non-empty.
|
||||
|
||||
#### M1.5 Verification Gate Result
|
||||
|
||||
| Rebuild # | Run ID / commit SHA | All 12 items green? | Attestor identity | Attested at (UTC) |
|
||||
|-----------|---------------------|---------------------|-------------------|-------------------|
|
||||
| 1 | _(operator fills)_ | _(yes/no)_ | _(operator fills)_ | _(operator fills)_ |
|
||||
| 2 | _(operator fills)_ | _(yes/no)_ | _(operator fills)_ | _(operator fills)_ |
|
||||
| 3 | _(operator fills)_ | _(yes/no)_ | _(operator fills)_ | _(operator fills)_ |
|
||||
|
||||
> **P6 audit rule:** all three rows must be present, all 12 items
|
||||
> green on each, the three run IDs/SHAs distinct (consecutive
|
||||
> rebuilds, not one run copied thrice), and the attestor identity
|
||||
> non-empty. Empty or red → P6 blocks → escalate.
|
||||
|
||||
## 4. M2 operational handoff loop
|
||||
|
||||
M2 is the steady-state operational loop for rolling out an engineering
|
||||
change after M1.5 is green. The loop is the tag-pin bump → plan →
|
||||
HITL → apply cycle (D-235, D-238).
|
||||
|
||||
1. **Tag-pin bump** — the operator opens a PR in `nova-platform-ops`
|
||||
bumping `local.nova_platform_version` (e.g. `v1.29.3` → `v1.29.4`)
|
||||
+ `local.kj_source_sha` (the `kj` source SHA from
|
||||
`platform/abac/kj-version.txt` at the new tag). Both pins move
|
||||
together — there is one ECR image identity (KJ-LOCKSTEP, REQ-371).
|
||||
|
||||
2. **`terraform plan`** — CI runs `terraform plan` on the PR. The
|
||||
KJ-LOCKSTEP precondition (a `lifecycle.precondition` on both
|
||||
image-bearing resources — the Lambda `image_uri` and the Fargate
|
||||
task `container_definitions[0].image`) checks that both
|
||||
`image_uri` attributes resolve to the **same ECR digest** via
|
||||
`data.aws_ecr_image.kj_image`. If the two diverge, the plan fails
|
||||
closed — no second pipeline, no second SHA pin (D-238).
|
||||
|
||||
3. **HITL approval** — a Gitea Actions approver **distinct from the
|
||||
PR author** approves the apply (TFM-HITL, REQ-357). Self-approval
|
||||
is rejected (M1.5 item 11).
|
||||
|
||||
4. **`terraform apply`** — on approval, the apply updates both
|
||||
`aws_lambda_function.nova_idp_token_vend.image_uri` and
|
||||
`aws_ecs_task_definition.kj.container_definitions[0].image` to the
|
||||
same ECR digest. The Lambda image + the Fargate task redeploy to
|
||||
the same digest in one apply. Zero diff on KMS, DDB, IAM, edge
|
||||
(the only change is the image reference).
|
||||
|
||||
**Verification:** after the apply, `aws lambda get-function
|
||||
--function-name nova-idp-token-vend --query Configuration.Code.ImageUri`
|
||||
and `aws ecs describe-tasks` on the Fargate task both report the same
|
||||
digest. This is the M2 acceptance gate (PLAN §UX Acceptance Criteria
|
||||
3) + the REQ-371 Result row in §18.
|
||||
|
||||
## 5. M2a Fargate activation (conditional)
|
||||
|
||||
M2a activates **only if M1.5 fails 3 consecutive rebuilds** (D-236).
|
||||
It is the REQ-363b Fargate toggle — an always-warm minimal Fargate
|
||||
standby running the **same ECR image** as the Lambda (KJ-LOCKSTEP).
|
||||
|
||||
Because the standby is always running the same digest as the Lambda,
|
||||
activating M2a is **not** a warmup hit — the standby is already
|
||||
serving `GET /health → 200` every 10s (KJ-WARMUP-HEALTH, §15). The
|
||||
toggle repoints token-vend traffic from the Lambda to the Fargate
|
||||
task; no cold start, no image pull.
|
||||
|
||||
If both the Lambda path and the Fargate path fail (M1.5 items 1-7
|
||||
fail on both substrates), the operator escalates — Nova-idp ships in
|
||||
read-only partial mode (no token issuance) until `kj` is verified
|
||||
(Q7 carry-forward, spec §7.7).
|
||||
|
||||
**Fargate sunset discipline (D-237):** the standby (~$15-20/month,
|
||||
§7) may not be deleted unless REQ-363 has been green in production
|
||||
for **≥30 consecutive days**. Sunset requires an architecture review.
|
||||
See §15 for the health-check procedure.
|
||||
|
||||
## 6. Rollback procedure (D-236)
|
||||
|
||||
Rollback is a tag-pin revert — the same mechanism as the M2 rollout
|
||||
(§4), in reverse.
|
||||
|
||||
1. **Revert `nova_platform_version`** in `nova-platform-ops` to the
|
||||
prior tag (e.g. `v1.29.4` → `v1.29.3`). Open a PR, get HITL
|
||||
approval (TFM-HITL, same as rollout).
|
||||
|
||||
2. **`terraform apply`** — the apply reverts both the Lambda
|
||||
`image_uri` and the Fargate task `image` to the prior ECR digest.
|
||||
The prior tag's artifacts remain downloadable (GitHub Releases are
|
||||
append-only per tag, REQ-354 AC 2) — no artifact is re-built.
|
||||
|
||||
3. **Verify** the prior digest is running:
|
||||
```sh
|
||||
aws lambda get-function --function-name nova-idp-token-vend \
|
||||
--query Configuration.Code.ImageUri --output text
|
||||
# → <account>.dkr.ecr.us-east-1.amazonaws.com/nova-kj@sha256:<prior-digest>
|
||||
```
|
||||
|
||||
This is the M1.5 item 12 rollback drill + the operational rollback
|
||||
procedure. It guards against ECR tag mutability (RESEARCH §2) — the
|
||||
digest is immutable even if a tag is re-pushed.
|
||||
|
||||
## 7. Cost section
|
||||
|
||||
Monthly estimate for the `nova-platform-ops` live platform in account
|
||||
`581513795199` (pilot volume):
|
||||
|
||||
| Resource | Quantity | Est. monthly | Notes |
|
||||
|----------|----------|-------------|-------|
|
||||
| WAF WebACL (CloudFront-scoped) | 1 | ~$5-10 | + per-request; REQ-365 |
|
||||
| Fargate standby (0.25 vCPU, 512 MB) | 1 task | ~$15-20 | REQ-363b AC 4; largest line item |
|
||||
| KMS asymmetric key | 1 | ~$1 | `alias/nova-oidc-signing`, ECC_NIST_P256 |
|
||||
| DynamoDB (on-demand, 6 tables) | 6 | ~$2 | §11 tables |
|
||||
| Lambda invocations (3 Lambdas) | 3 | ~$2 | low pilot volume |
|
||||
| ECR image storage | ~100 MB | <$1 | the `kj` image |
|
||||
| S3 state bucket + access logs | 1 | <$1 | `nova-tfstate-*` |
|
||||
| CloudFront + ACM + Route53 | 1 distribution | ~$1 | ACM free for CloudFront-attached |
|
||||
| **Total** | | **~$30-40/month** | |
|
||||
|
||||
**Fargate standby is the largest line item** (~$15-20/month, REQ-363b
|
||||
AC 4). It is explicitly documented here with the D-237 sunset
|
||||
discipline (§5, §15): ≥30 consecutive days green before deletion +
|
||||
architecture review. Do not delete the standby to save ~$15/month
|
||||
without that review — it is the defensive fallback for the `kj`
|
||||
substrate.
|
||||
|
||||
## 8. Artifact-mirror fallback (Edge 6)
|
||||
|
||||
When the Gitea `act_runner` in `nova-platform-ops` CI cannot reach
|
||||
GitHub Releases (network partition, egress restriction, GitHub
|
||||
outage), the operator mirrors the artifact bundle locally by SHA-256.
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. **Download the GitHub Release bundle** for the target tag
|
||||
(`v1.29.x`) from a machine that can reach GitHub Releases:
|
||||
```sh
|
||||
gh release download v1.29.0 \
|
||||
--repo continuous-intelligence/acdl \
|
||||
--pattern 'nova-lambda-token-vend-*.zip' \
|
||||
--pattern 'nova-cli-layer-*.zip' \
|
||||
--pattern 'nova-*-py3-none-any.whl' \
|
||||
--dir ./artifact-cache
|
||||
```
|
||||
|
||||
2. **Verify SHA-256** against the release body (each artifact's
|
||||
SHA-256 is listed in the GitHub Release body, REQ-354):
|
||||
```sh
|
||||
sha256sum ./artifact-cache/nova-lambda-token-vend-v1.29.0.zip
|
||||
# → must match the SHA-256 in the release body
|
||||
```
|
||||
|
||||
3. **Place the bundle in the operator's local artifact cache** — a
|
||||
directory the `act_runner` can read (e.g. a Gitea-lfs-tracked path
|
||||
in `nova-platform-ops`, or an S3 bucket the runner can reach).
|
||||
|
||||
4. **Reference by SHA-256 in the terraform variables** — the
|
||||
`nova-platform-ops` Terraform accepts an override for the artifact
|
||||
source: `nova_artifact_mirror_sha256 = "<sha256>"`. When set, the
|
||||
`data` sources resolve from the local cache by SHA-256 instead of
|
||||
from GitHub Releases. Unset → resume GitHub Releases resolution.
|
||||
|
||||
This fallback is for CI continuity only; the live `terraform apply`
|
||||
still resolves the ECR image by digest (KJ-LOCKSTEP), which is
|
||||
independent of GitHub Releases availability.
|
||||
|
||||
## 9. KMS rotation (D-234)
|
||||
|
||||
The OIDC signing key `alias/nova-oidc-signing` is provisioned with
|
||||
`KeySpec: ECC_NIST_P256`, `KeyUsage: SIGN_VERIFY`, on a **90-day
|
||||
rotation cadence** (matches per-stack CMK rotation per D-069).
|
||||
|
||||
**Verify the key spec + rotation status:**
|
||||
```sh
|
||||
aws kms describe-key --key-id alias/nova-oidc-signing \
|
||||
--query 'KeyMetadata.[KeySpec,KeyUsage,Description]' --output text
|
||||
# → ECC_NIST_P256 SIGN_VERIFY <description>
|
||||
```
|
||||
|
||||
**Apply a rotation policy** (key re-point, not key deletion — the
|
||||
alias moves to a new key while the old key stays valid during the
|
||||
overlap window, §17 JWKS-ROTATION):
|
||||
|
||||
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:
|
||||
```sh
|
||||
aws kms update-alias --alias-name alias/nova-oidc-signing \
|
||||
--target-key-id "$NEW_KEY"
|
||||
```
|
||||
|
||||
3. Apply the rotation policy (the key policy grants `kms:Sign` to the
|
||||
token-vend Lambda role + `kms:GetPublicKey` to the JWKS Lambda
|
||||
role):
|
||||
```sh
|
||||
aws kms put-key-policy --key-id "$NEW_KEY" \
|
||||
--policy-name default --policy file://kms-signing-key-policy.json
|
||||
```
|
||||
|
||||
4. After the 24-hour overlap window (§17), 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: `nova idp setup --verify` (the KMS
|
||||
round-trip test, REQ-362).
|
||||
|
||||
**Audit:** emit a `kms.key_rotated` event with `old_key_id`,
|
||||
`new_key_id`, `rotated_at`.
|
||||
|
||||
## 10. JWKS reachability via CloudFront edge (D-233, INV-18)
|
||||
|
||||
The JWKS endpoint is the **only public read surface** of the live
|
||||
platform (INV-18, D-233). All other platform endpoints gate with
|
||||
`AuthType: AWS_IAM`. CloudFront + OAC pinning replaces direct Lambda
|
||||
Function URL exposure.
|
||||
|
||||
**Critical pitfall (RESEARCH §4):** the JWKS Function URL
|
||||
`AuthType` MUST be `AWS_IAM`, NOT `NONE`. A common mistake is to set
|
||||
`AuthType: NONE` on the Function URL (thinking CloudFront is the
|
||||
gate) — that exposes the JWKS endpoint directly to the internet,
|
||||
bypassing OAC. The correct configuration:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Function URL `AuthType` | `AWS_IAM` (NOT `NONE`) |
|
||||
| CloudFront OAC `OriginAccessControlOriginType` | `lambda` |
|
||||
| CloudFront OAC `SigningBehavior` | `always` |
|
||||
| Lambda resource policy | `lambda:InvokeFunctionUrl` scoped to the CloudFront distribution ARN |
|
||||
|
||||
With `AuthType: AWS_IAM` + OAC `always` signing, CloudFront signs
|
||||
every origin request with SigV4; a direct Function URL request has no
|
||||
SigV4 signature → 403. Only CloudFront can reach the origin.
|
||||
|
||||
**Verification (M1.5 item 9):**
|
||||
```sh
|
||||
# Via CloudFront → 200
|
||||
curl -sI https://<jwks-domain>/.well-known/jwks.json | head -1
|
||||
# → HTTP/2 200
|
||||
|
||||
# Direct Function URL → 403
|
||||
curl -sI "<raw-function-url>/.well-known/jwks.json" | head -1
|
||||
# → HTTP/2 403
|
||||
```
|
||||
|
||||
If the direct Function URL returns 200, the `AuthType` is `NONE` —
|
||||
fix the Terraform + re-apply before declaring M1.5 green.
|
||||
|
||||
## 11. PITR restore (data-engineer section)
|
||||
|
||||
DynamoDB point-in-time recovery (PITR) is enabled on every identity +
|
||||
contract table. PITR lets you restore a table to any second in the
|
||||
last **35 days** (the AWS retention window).
|
||||
|
||||
**Tables with PITR enabled:**
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `nova-contracts` | contract ingestor records |
|
||||
| `nova-change-requests` | change request ledger |
|
||||
| `nova-outbox` | audit outbox |
|
||||
| `nova-users` | Nova-idp users (Argon2id hashes) |
|
||||
| `nova-sessions` | Nova-idp sessions (TTL `expires_at`) |
|
||||
| `nova-pats` | Nova-idp PATs (revocation strong-read, D-229) |
|
||||
|
||||
**Enable PITR (on a new/restored table — PITR does not carry over
|
||||
from the source):**
|
||||
```sh
|
||||
aws dynamodb update-continuous-backups \
|
||||
--table-name <table> \
|
||||
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
|
||||
```
|
||||
|
||||
**Restore a table to a point in time** (PITR never overwrites the
|
||||
source — restore to a NEW table, then repoint):
|
||||
```sh
|
||||
RESTORE_TO=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
aws dynamodb restore-table-to-point-in-time \
|
||||
--source-table-name <table> \
|
||||
--target-table-name <table>-restored \
|
||||
--restore-date-time "$RESTORE_TO" \
|
||||
--billing-mode-restore-as-is
|
||||
|
||||
# After the restore completes (status ACTIVE), repoint the app:
|
||||
# - update the stack env var to the restored table name, or
|
||||
# - rename: delete <table>, then update-table --new-table-name <table>
|
||||
# Then re-enable PITR on the restored table (see above).
|
||||
```
|
||||
|
||||
**Verify PITR is enabled on all tables:**
|
||||
```sh
|
||||
for t in nova-contracts nova-change-requests nova-outbox \
|
||||
nova-users nova-sessions nova-pats; do
|
||||
aws dynamodb describe-continuous-backups --table-name "$t" \
|
||||
--query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus' \
|
||||
--output text
|
||||
done
|
||||
# → ENABLED (x6)
|
||||
```
|
||||
|
||||
Restores older than 35 days are impossible — for longer retention,
|
||||
export to S3 via the on-demand export or a scheduled AWS Backup plan.
|
||||
|
||||
## 12. DynamoDB import addresses (REQ-361, covered-reference)
|
||||
|
||||
The `importable-resources.tf` map in `nova-platform-ops` lists the
|
||||
existing live resources that `terraform import` adopts at M1 cutover
|
||||
(§2 step 4). Re-import exits non-zero with
|
||||
`resource_already_imported` (IMPORT-IDEMPOTENT); CI import treats this
|
||||
as idempotent success.
|
||||
|
||||
| Terraform address | AWS resource | Type |
|
||||
|-------------------|--------------|------|
|
||||
| `aws_s3_bucket.nova_tfstate` | `nova-tfstate-581513795199-us-east-1` | S3 bucket (state backend) |
|
||||
| `aws_dynamodb_table.nova_contracts` | `nova-contracts` | DynamoDB table |
|
||||
| `aws_dynamodb_table.nova_change_requests` | `nova-change-requests` | DynamoDB table |
|
||||
| `aws_dynamodb_table.nova_outbox` | `nova-outbox` | DynamoDB table |
|
||||
| `aws_iam_role.acdl_act_runner` | `acdl-act-runner-role` | IAM role (reused, spec §7.6) |
|
||||
| `aws_kms_key.<per_stack_cmk>` | per-stack CMKs | KMS key (one per stack) |
|
||||
|
||||
The identity tables (`nova-users`, `nova-sessions`, `nova-pats`) are
|
||||
**new** resources added at M1 (§2 step 6), not imported — they do
|
||||
not yet exist in the account at M1.
|
||||
|
||||
## 13. PAT revocation (D-229)
|
||||
|
||||
PAT revocation has a **60s SLO**: the token-vend Lambda does a
|
||||
strongly-consistent DynamoDB read (`ConsistentRead=True`) on every
|
||||
token-vend request. A revoked PAT is reflected on the next vend,
|
||||
within 60s P95.
|
||||
|
||||
**Verify a PAT's revocation status (strong read):**
|
||||
```sh
|
||||
aws dynamodb get-item \
|
||||
--table-name nova-pats \
|
||||
--key '{"jti":{"S":"<pat-id>"}}' \
|
||||
--consistent-read \
|
||||
--query 'Item.status.S' --output text
|
||||
# → active (still valid)
|
||||
# → revoked (next token-vend returns 403)
|
||||
```
|
||||
|
||||
**Revoke a PAT at the DDB level** (emergency — when the CLI is
|
||||
unavailable; the `jti` is known but the raw PAT is not):
|
||||
```sh
|
||||
aws dynamodb update-item \
|
||||
--table-name nova-pats \
|
||||
--key '{"jti":{"S":"<pat-id>"}}' \
|
||||
--update-expression "SET #s = :r" \
|
||||
--expression-attribute-names '{"#s":"status"}' \
|
||||
--expression-attribute-values '{":r":{"S":"revoked"}}'
|
||||
```
|
||||
|
||||
The item is **retained** (not deleted) so the audit trail is intact —
|
||||
only `status` flips from `active` to `revoked`. The next `token-vend`
|
||||
call with that `jti` returns `403 pat_revoked` immediately (D-229:
|
||||
the strong read is synchronous).
|
||||
|
||||
## 14. Edge configuration (REQ-364/365/366, covered-reference)
|
||||
|
||||
The edge stack fronts the JWKS Lambda with CloudFront + WAF + ACM +
|
||||
Route53. This is the public read surface (§10, INV-18).
|
||||
|
||||
### CloudFront + OAC (REQ-364)
|
||||
|
||||
- Distribution origin = the JWKS Lambda Function URL.
|
||||
- OAC: `OriginAccessControlOriginType: lambda`,
|
||||
`SigningBehavior: always` (§10).
|
||||
- Cache behavior: `Cache-Control: max-age=3600` honored (JWKS-SLO).
|
||||
|
||||
### WAF WebACL (REQ-365)
|
||||
|
||||
- Scope: `CLOUDFRONT` (the WebACL is in `us-east-1`, the only region
|
||||
for CloudFront-scoped WebACLs).
|
||||
- Rate-based rule: `RateBasedStatement` with `Limit: 3000`,
|
||||
`AggregateKeyType: IP`, `EvaluationWindowSec: 300` (3000 requests
|
||||
per 5 minutes per IP).
|
||||
- Managed rules: `AWSManagedRulesCommonRuleSet` (the AWS managed rule
|
||||
group for common attacks).
|
||||
|
||||
### ACM certificate (REQ-366)
|
||||
|
||||
- Certificate in `us-east-1` (CloudFront requires the cert in
|
||||
us-east-1).
|
||||
- DNS validation (a CNAME record per validation record is written to
|
||||
Route53). The cert status MUST be `ISSUED` (not
|
||||
`PENDING_VALIDATION`) before the CloudFront distribution can serve
|
||||
the domain.
|
||||
|
||||
### Route53 (REQ-366)
|
||||
|
||||
- An A-alias record pointing to the CloudFront distribution's domain
|
||||
name.
|
||||
|
||||
### `route53_record_not_resolvable` debugging
|
||||
|
||||
If the JWKS domain does not resolve (`route53_record_not_resolvable`
|
||||
or `NXDOMAIN`):
|
||||
|
||||
1. **Check ACM cert status:**
|
||||
```sh
|
||||
aws acm describe-certificate --certificate-arn <arn> \
|
||||
--query 'Certificate.Status' --output text
|
||||
# → must be ISSUED, not PENDING_VALIDATION
|
||||
```
|
||||
If `PENDING_VALIDATION`, the DNS validation CNAME records are not
|
||||
in Route53 (or not propagated). Re-apply the validation records +
|
||||
wait for AWS to validate (typically minutes).
|
||||
|
||||
2. **Check CloudFront status:**
|
||||
```sh
|
||||
aws cloudfront get-distribution --id <id> \
|
||||
--query 'Distribution.Status' --output text
|
||||
# → must be Deployed
|
||||
```
|
||||
If `InProgress`, wait for the deployment to finish. CloudFront
|
||||
deployments take ~5-15 minutes.
|
||||
|
||||
3. **Check the Route53 alias record** points to the CloudFront
|
||||
distribution domain name (not the Function URL).
|
||||
|
||||
## 15. Fargate standby health (KJ-WARMUP-HEALTH, REQ-363b)
|
||||
|
||||
The Fargate standby is the always-warm minimal defensive fallback
|
||||
(REQ-363b). It runs the **same ECR image** as the Lambda (KJ-LOCKSTEP,
|
||||
REQ-371) — so it is always running the current digest, never a stale
|
||||
one.
|
||||
|
||||
**Health probe:** `GET /health → 200` every **10s**
|
||||
(KJ-WARMUP-HEALTH).
|
||||
|
||||
**Failure handling:** 3 consecutive probe failures → alert + the
|
||||
token-vend path **fails closed** (no signing). The standby does not
|
||||
silently degrade — if it is not healthy, token-vend does not fall
|
||||
back to it; it fails closed (INV-17 ABAC discipline extended to the
|
||||
substrate).
|
||||
|
||||
**Verify the standby is `READY` before M1 cutover:**
|
||||
```sh
|
||||
# The Fargate task health check (target group)
|
||||
aws elbv2 describe-target-health \
|
||||
--target-group-arn <tg-arn> \
|
||||
--query 'TargetHealthDescriptions[0].TargetHealth.State' --output text
|
||||
# → healthy
|
||||
|
||||
# Direct probe
|
||||
curl -sI https://<fargate-endpoint>/health | head -1
|
||||
# → HTTP/1.1 200
|
||||
```
|
||||
|
||||
**Fargate sunset discipline (D-237):** the standby may not be deleted
|
||||
unless REQ-363 has been green in production for **≥30 consecutive
|
||||
days**. Sunset requires an architecture review. Do not delete the
|
||||
standby to save ~$15/month (§7) without that review — it is the
|
||||
defensive fallback for the `kj` substrate.
|
||||
|
||||
## 16. IAM scope (IAM-NARROW, REQ-360, covered-reference)
|
||||
|
||||
The Gitea OIDC role for `act_runner` (reused `acdl-act-runner-role`,
|
||||
spec §7.6) is bounded per REQ-360. **No `Action: "*"` or `Resource:
|
||||
"*"`** (IAM-NARROW).
|
||||
|
||||
The scope covers only:
|
||||
|
||||
| Action | Scope | Why |
|
||||
|--------|-------|-----|
|
||||
| `kms:*` | customer-managed keys in `581513795199` | KMS signing + rotation |
|
||||
| `dynamodb:*` | tables prefixed `nova-` | identity + contract tables |
|
||||
| `lambda:*` | functions prefixed `nova-` | the 3 Nova-idp Lambdas |
|
||||
| `s3:*` | buckets prefixed `nova-` | state bucket + artifact cache |
|
||||
| `cloudfront:*` | tagged resources | the JWKS distribution |
|
||||
| `wafv2:*` | tagged resources | the WebACL |
|
||||
| `acm:*` | tagged resources | the JWKS cert |
|
||||
| `route53:*` | tagged resources | the JWKS alias |
|
||||
| `iam:PassRole` | roles tagged `nova-ops-only` | pass roles to Lambda/ECS only |
|
||||
|
||||
**Verify (M1.5 item 10):**
|
||||
```sh
|
||||
aws iam get-role-policy --role-name acdl-act-runner-role \
|
||||
--policy-name <policy-name> --query 'PolicyDocument' --output json \
|
||||
| jq '.Statement[].Action, .Statement[].Resource'
|
||||
# → no "*" in either list
|
||||
```
|
||||
|
||||
If `Action: "*"` or `Resource: "*"` appears, the IAM policy is too
|
||||
broad — fix the Terraform + re-apply before declaring M1.5 green.
|
||||
|
||||
## 17. JWKS-ROTATION
|
||||
|
||||
On KMS key rotation (§9), **both old + new public keys** are
|
||||
published in the JWKS during a **24-hour overlap window**. The old
|
||||
key is removed from the JWKS only after consumers pick up the new
|
||||
one.
|
||||
|
||||
- During the overlap: 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.**
|
||||
- After the overlap, the old key is removed from the JWKS + disabled +
|
||||
scheduled for deletion (§9 step 4).
|
||||
|
||||
This is JWKS-ROTATION (NFR) — the rotation is non-disruptive because
|
||||
consumers cache the JWKS for up to `max-age=3600` (1h, JWKS-SLO) and
|
||||
re-fetch within that window, picking up both keys during the overlap.
|
||||
|
||||
## 18. Cutover Gates (grill CF-2/G-5)
|
||||
|
||||
Each covered-reference REQ has a cutover gate (M1/M1.5/M2) with a
|
||||
verification command + a **"Result" column**. The operator fills the
|
||||
Result column after running the gate in `nova-platform-ops` CI.
|
||||
**P6 audit verifies every covered-reference REQ has a non-empty,
|
||||
green Result.** Empty or red → P6 blocks (grill CF-2/G-5).
|
||||
|
||||
| REQ | Gate | Verification command | Result |
|
||||
|-----|------|----------------------|--------|
|
||||
| REQ-355 | M1 | `terraform plan` resolves `data.aws_ecr_image.kj_image` from `local.nova_platform_version` + `local.kj_source_sha`; both image_uri attributes present | _(operator fills: green/red + run ID/SHA + attestor)_ |
|
||||
| REQ-356 | M1 | `terraform plan` exit 0 (no drift) on a clean checkout of `main`; exit 2 → `drift_detected` fails the apply workflow | _(operator fills)_ |
|
||||
| REQ-357 | M1.5 | `terraform apply` `workflow_dispatch` triggered by PR author → rejected; distinct user → proceeds (M1.5 item 11) | _(operator fills)_ |
|
||||
| REQ-358 | M2 | bump `nova_platform_version` → `terraform apply` → `aws lambda get-function ... ImageUri` `CodeSha256` matches the artifact SHA-256 from the GitHub Release body | _(operator fills)_ |
|
||||
| REQ-359 | M1 | `git -C nova-platform-ops remote -v` shows only the Gitea private remote (no GitHub mirror); Gitea repo `private: true` | _(operator fills)_ |
|
||||
| REQ-360 | M1.5 | `aws iam get-role-policy` on the OIDC role asserts no `Action: "*"` + no `Resource: "*"` (M1.5 item 10, §16) | _(operator fills)_ |
|
||||
| REQ-361 | M1 | `terraform import` on each address in `importable-resources.tf` (§12) succeeds; re-import exits `resource_already_imported` → CI treats as idempotent success (IMPORT-IDEMPOTENT) | _(operator fills)_ |
|
||||
| REQ-362 | M1.5 | `nova idp setup --verify` (KMS round-trip) against `alias/nova-oidc-signing` (`ECC_NIST_P256`, `SIGN_VERIFY`) → `{"passed":true}` (M1.5 item 6) | _(operator fills)_ |
|
||||
| REQ-363 | M1.5 | `nova_idp_token_vend.handler` invokes `subprocess.run(['/opt/kj/kj','apply',...])` on the live image; `file(1)` reports `statically linked` (M1.5 items 2-4, KJ-STATIC) | _(operator fills)_ |
|
||||
| REQ-363b | M1.5 | Fargate standby `GET /health → 200` every 10s (KJ-WARMUP-HEALTH); same ECR digest as the Lambda (KJ-LOCKSTEP); activates only if M1.5 items 1-7 fail 3× (§5) | _(operator fills)_ |
|
||||
| REQ-364 | M1.5 | direct JWKS Function URL → 403; via-CloudFront → 200 (M1.5 item 9, §10, INV-18) | _(operator fills)_ |
|
||||
| REQ-365 | M1 | `aws wafv2 get-web-acl` shows `RateBasedStatement` Limit 3000, AggregateKeyType IP, EvaluationWindowSec 300 + `AWSManagedRulesCommonRuleSet`; Scope CLOUDFRONT in us-east-1 (§14) | _(operator fills)_ |
|
||||
| REQ-366 | M1 | `aws acm describe-certificate` Status `ISSUED`; Route53 A-alias resolves to the CloudFront distribution domain (§14) | _(operator fills)_ |
|
||||
| REQ-371 | M2 | after `terraform apply`, both `aws_lambda_function.nova_idp_token_vend.image_uri` and `aws_ecs_task_definition.kj.container_definitions[0].image` report the same ECR digest (KJ-LOCKSTEP precondition green at plan) | _(operator fills)_ |
|
||||
|
||||
> **P6 audit rule (grill CF-2/G-5):** every row's Result column must
|
||||
> be non-empty + green. An empty or red Result blocks the milestone
|
||||
> ship. The operator attestation is the acdl-side evidence surface;
|
||||
> the live verification runs in `nova-platform-ops` CI.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — quick reference
|
||||
|
||||
| Procedure | Cadence / trigger | Section |
|
||||
|-----------|-------------------|---------|
|
||||
| Day-0 cutover (M1) | one-time | §2 |
|
||||
| M1.5 verification gate | one-time (3 consecutive green rebuilds) | §3 |
|
||||
| M2 operational handoff | per engineering change (tag-pin bump) | §4 |
|
||||
| M2a Fargate activation | conditional (M1.5 fails 3×) | §5 |
|
||||
| Rollback | on regression | §6 |
|
||||
| Artifact-mirror fallback | on GitHub Releases unreachable | §8 |
|
||||
| KMS rotation | every 90 days | §9 |
|
||||
| JWKS-ROTATION overlap | on each KMS rotation (24h window) | §17 |
|
||||
| PITR restore | on data loss / corruption (35-day window) | §11 |
|
||||
| Emergency PAT revocation | on compromise (DDB-level, immediate) | §13 |
|
||||
| Fargate standby health check | continuous (every 10s) | §15 |
|
||||
| Fargate sunset | ≥30 consecutive days green + architecture review | §5, §15 |
|
||||
| `route53_record_not_resolvable` debug | on JWKS domain not resolving | §14 |
|
||||
| Cutover gate attestation | at M1/M1.5/M2 (operator fills Result column) | §18 |
|
||||
@@ -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 @@
|
||||
"""Nova CLI package — thin subcommand delegates to core.* (P1, REQ-324)."""
|
||||
@@ -0,0 +1,45 @@
|
||||
"""nova apply — resolve a contract + synthesize local env (REQ-330, REQ-332).
|
||||
|
||||
Subcommand (≤50 lines, ≤3 functions, delegates to core/ — NFR-7).
|
||||
nova apply --local --contract .nova/contract.yml [--sign-local-review]
|
||||
nova apply --contract contracts/microservice.yml --out stack.json
|
||||
|
||||
--local: calls core.env.synthesize_local_env() + core.contract_resolver.resolve()
|
||||
--sign-local-review: calls core.jws_attestation.sign_attestation() (REQ-332)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core import env
|
||||
from core.contract_resolver import resolve
|
||||
from core.jws_attestation import sign_attestation
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("apply", help="resolve a contract (+ local env synth)")
|
||||
p.add_argument("--contract", default=".nova/contract.yml", help="contract YAML path")
|
||||
p.add_argument("--out", default=None, help="output path (default: stdout)")
|
||||
p.add_argument("--local", action="store_true", help="synthesize a local env (no AWS)")
|
||||
p.add_argument("--environment", default=None, help="environment override")
|
||||
p.add_argument("--sign-local-review", action="store_true", help="sign a local-review attestation (REQ-332)")
|
||||
p.add_argument("--pat", default=None, help="PAT for --sign-local-review")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
synth = env.synthesize_local_env(args.contract, environment=args.environment) if args.local else None
|
||||
env_override = (synth["name"] if isinstance(synth, dict) else None) or args.environment
|
||||
result = resolve(args.contract, environment_override=env_override)
|
||||
blob = json.dumps(result, indent=2) + "\n"
|
||||
pat = args.pat or env.get_env("PAT", "") or ""
|
||||
attestation = sign_attestation({"contract": args.contract, "review": "local"}, pat) if (args.sign_local_review and pat) else None
|
||||
blob = blob + (attestation + "\n" if attestation else "")
|
||||
print(blob) if args.out is None else open(args.out, "w").write(blob)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova apply --contract <contract.yml> [--local] [--sign-local-review]", file=sys.stderr)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""nova attestation-matrix — run the 8-concern attestation matrix (REQ-109)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.attestation_matrix import cli_main
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("attestation-matrix", help="run the 8-concern attestation matrix")
|
||||
p.add_argument("env", help="target environment (dev/qa/prod/dr)")
|
||||
p.add_argument("evidence", nargs="?", default=None, help="evidence JSON path")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
argv = ["nova-attestation-matrix", args.env] + ([args.evidence] if args.evidence else [])
|
||||
return cli_main(argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova attestation-matrix <env> [evidence.json]", file=sys.stderr)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""nova auth — login / revoke / status subcommands (REQ-344, C-7.3).
|
||||
|
||||
Subpackage entry point: ``add_parser`` registers the ``auth`` subparser
|
||||
with ``login``/``revoke``/``status`` sub-subcommands, each delegating to
|
||||
its module's ``run``. Discovered by ``nova/cli.py`` via
|
||||
``pkgutil.iter_modules`` (this package's ``add_parser`` is the hook).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("auth", help="Nova IdP auth (login/revoke/status)")
|
||||
sub = p.add_subparsers(dest="auth_command", required=True)
|
||||
from nova.auth import login as _login, revoke as _revoke, status as _status
|
||||
_login.add_parser(sub)
|
||||
_revoke.add_parser(sub)
|
||||
_status.add_parser(sub)
|
||||
return p
|
||||
@@ -0,0 +1,58 @@
|
||||
"""nova auth login — session/PAT → OIDC token, store locally (REQ-344, C-7.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from core.auth_store import store_credential, credentials_path
|
||||
|
||||
|
||||
def _vend(pat: str, env: str, endpoint: str) -> dict:
|
||||
"""Call the token-vend Lambda (locally or via the function URL)."""
|
||||
if endpoint and endpoint.startswith("http"):
|
||||
import urllib.request
|
||||
body = json.dumps({"token": pat, "environment": env}).encode()
|
||||
req = urllib.request.Request(endpoint, data=body, headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
# Local: invoke the Lambda in-process.
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
p = Path(__import__("core").__file__).parent / "lambda" / "nova_idp_token_vend.py"
|
||||
spec = importlib.util.spec_from_file_location("nova_idp_token_vend", p)
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
resp = mod.lambda_handler({"body": json.dumps({"token": pat, "environment": env})}, None)
|
||||
return json.loads(resp["body"])
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("login", help="exchange a PAT/session for an OIDC token")
|
||||
p.add_argument("--pat", default=None, help="PAT JWT (prompted if absent)")
|
||||
p.add_argument("--session", default=None, help="session token (alias for --pat)")
|
||||
p.add_argument("--environment", default="dev", help="target environment")
|
||||
p.add_argument("--endpoint", default=os.environ.get("NOVA_TOKEN_VEND_URL", ""),
|
||||
help="token-vend function URL (empty = local)")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
pat = args.pat or args.session or os.environ.get("NOVA_PAT")
|
||||
if not pat:
|
||||
pat = sys.stdin.readline().strip()
|
||||
if not pat:
|
||||
print("error: no PAT/session provided", file=sys.stderr); return 1
|
||||
result = _vend(pat, args.environment, args.endpoint)
|
||||
if "token" not in result:
|
||||
print(f"error: {result.get('error', result)}", file=sys.stderr); return 2
|
||||
import base64
|
||||
payload = json.loads(base64.urlsafe_b64decode(result["token"].split(".")[1] + "=="))
|
||||
store_credential(
|
||||
jti=payload.get("jti", ""), cred_type=payload.get("typ", "nova_oidc_token"),
|
||||
exp=payload.get("exp", 0), oidc_token=result["token"],
|
||||
)
|
||||
print(f"logged in: jti={payload.get('jti')} exp={payload.get('exp')} "
|
||||
f"file={credentials_path()}")
|
||||
return 0
|
||||
@@ -0,0 +1,38 @@
|
||||
"""nova auth revoke --pat <jti> — revoke a PAT (REQ-344, D-229)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from core.auth_store import emit_revoke_audit
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("revoke", help="revoke a PAT by jti")
|
||||
p.add_argument("--pat", required=True, help="PAT jti to revoke")
|
||||
p.add_argument("--endpoint", default=os.environ.get("NOVA_TOKEN_VEND_URL", ""),
|
||||
help="token-vend function URL (empty = local DDB)")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def _revoke_remote(jti: str, endpoint: str) -> dict:
|
||||
import json, urllib.request
|
||||
body = json.dumps({"action": "revoke_pat", "jti": jti}).encode()
|
||||
req = urllib.request.Request(endpoint, data=body, headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
try:
|
||||
if args.endpoint and args.endpoint.startswith("http"):
|
||||
_revoke_remote(args.pat, args.endpoint)
|
||||
else:
|
||||
from core.pat_lifecycle import revoke_pat
|
||||
revoke_pat(args.pat)
|
||||
emit_revoke_audit(args.pat)
|
||||
print(f"revoked: jti={args.pat}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"error: {e}", file=sys.stderr); return 2
|
||||
@@ -0,0 +1,34 @@
|
||||
"""nova auth status — print active credential + mode (REQ-344)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from core.auth_store import active_credential, emit_status_audit
|
||||
from core.mode_resolver import resolve_mode_from_env
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("status", help="show active credential + client mode")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
cred = active_credential()
|
||||
emit_status_audit()
|
||||
mode, reason = resolve_mode_from_env(
|
||||
credential_type=cred.get("type") if cred else None,
|
||||
)
|
||||
if cred is None:
|
||||
print(f"no active credential (mode={mode}, reason={reason})")
|
||||
return 0
|
||||
print(json.dumps({
|
||||
"active_credential_jti": cred.get("jti"),
|
||||
"type": cred.get("type"),
|
||||
"exp": cred.get("exp"),
|
||||
"mode": mode,
|
||||
"selection_reason": reason,
|
||||
}, indent=2))
|
||||
return 0
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
"""Nova CLI entry point — dispatch + audit (P1, REQ-324, INV-12).
|
||||
|
||||
Auto-discovers nova/<module>.py subcommands; each exports
|
||||
add_parser(subparsers) + run(args) -> int. Resolves the client mode
|
||||
via core.mode_resolver and emits a cli.invocation audit event (stderr
|
||||
JSON line stub) before dispatching.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import json
|
||||
import pkgutil
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from core.mode_resolver import resolve_mode_from_env
|
||||
|
||||
|
||||
def _emit_invocation(mode, reason, cred_type, command, args):
|
||||
"""INV-12: emit cli.invocation audit event to stderr (stub)."""
|
||||
event = {
|
||||
"event": "cli.invocation",
|
||||
"mode": mode,
|
||||
"selection_reason": reason,
|
||||
"credential_type": cred_type,
|
||||
"command": command,
|
||||
"args": args,
|
||||
}
|
||||
sys.stderr.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
import nova
|
||||
|
||||
|
||||
def _build_parser():
|
||||
parser = argparse.ArgumentParser(prog="nova", description="Nova platform CLI")
|
||||
parser.add_argument("--mode", choices=["agent", "interactive"], default=None)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
for mod_info in pkgutil.iter_modules(nova.__path__):
|
||||
name = mod_info.name
|
||||
if name == "cli":
|
||||
continue
|
||||
mod = importlib.import_module(f"nova.{name}")
|
||||
mod.add_parser(sub)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list] = None) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
mode, reason = resolve_mode_from_env()
|
||||
arg_dict = {k: v for k, v in vars(args).items() if k != "_run"}
|
||||
_emit_invocation(mode, reason, None, args.command, arg_dict)
|
||||
return args._run(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""nova confidence — compute the confidence signal (REQ-19)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.confidence_signal import cli_main
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("confidence", help="compute the confidence signal")
|
||||
p.add_argument("inputs_json", help="path to an inputs JSON file")
|
||||
p.add_argument("environment", help="target environment")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
return cli_main(["nova-confidence", args.inputs_json, args.environment])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova confidence <inputs.json> <environment>", file=sys.stderr)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""nova decommission — transform a resolved stack for decommission (REQ-92)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.decommission_transform import decommission_transform
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("decommission", help="transform a stack JSON for decommission")
|
||||
p.add_argument("stack_json", help="path to a resolved stack JSON")
|
||||
p.add_argument("--out", default=None, help="output path (default: stdout)")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
with open(args.stack_json) as fh:
|
||||
stack = json.load(fh)
|
||||
out = decommission_transform(stack)
|
||||
blob = json.dumps(out, indent=2)
|
||||
print(blob)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova decommission <stack.json>", file=sys.stderr)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""nova env-check — check that an environment is bound (REQ-181)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from core.environment_check import check
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("env-check", help="check that an environment is bound")
|
||||
p.add_argument("contract", nargs="?", default=None, help="contract path")
|
||||
p.add_argument("--env", default=None, help="environment name override")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
ok, message = check(contract_path=args.contract, env_name=args.env)
|
||||
print(message) if ok else sys.stderr.write(message + "\n")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova env-check <contract.yml> [--env name]", file=sys.stderr)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""nova env-transition — detect/record the applied environment (REQ-183)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.env_transition import detect_prior_env, record_applied_env
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("env-transition", help="detect/record the env for a contract")
|
||||
sub = p.add_subparsers(dest="env_transition_command", required=True)
|
||||
pd = sub.add_parser("detect")
|
||||
pd.add_argument("--contract-id", required=True)
|
||||
pd.add_argument("--consumer-repo", required=True)
|
||||
pd.add_argument("--new-env", required=True)
|
||||
pr = sub.add_parser("record")
|
||||
pr.add_argument("--contract-id", required=True)
|
||||
pr.add_argument("--consumer-repo", required=True)
|
||||
pr.add_argument("--env", required=True)
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
cmd = args.env_transition_command
|
||||
payload = _dispatch(cmd, args)
|
||||
print(json.dumps(payload))
|
||||
return 0 if cmd == "detect" else (0 if payload["recorded"] else 1)
|
||||
|
||||
|
||||
def _dispatch(cmd, args) -> dict:
|
||||
return {"prior_env": detect_prior_env(args.contract_id, args.consumer_repo, args.new_env)} if cmd == "detect" else {"recorded": record_applied_env(args.contract_id, args.consumer_repo, args.env)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova env-transition detect|record ...", file=sys.stderr)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""nova hitl — attest a promotion gate (REQ-108)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from core.hitl_gates import attest, approver_from_env
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("hitl", help="attest a promotion gate")
|
||||
p.add_argument("--contract-id", required=True)
|
||||
p.add_argument("--env", required=True, help="dev/qa/prod/dr")
|
||||
p.add_argument("--evidence", default=None, help="evidence JSON path")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
evidence = _load_evidence(args.evidence)
|
||||
approver = approver_from_env() or ""
|
||||
ok, reason = attest(args.contract_id, args.env, approver, evidence)
|
||||
print(f"HITL PASS: {reason}") if ok else sys.stderr.write(f"HITL BLOCK: {reason}\n")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _load_evidence(path):
|
||||
return {} if path is None else json.loads(open(path).read())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova hitl --contract-id <id> --env <env> [--evidence f.json]", file=sys.stderr)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""nova idp — IdP setup subcommands (REQ-340, C-2.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("idp", help="Nova IdP management (setup)")
|
||||
sub = p.add_subparsers(dest="idp_command", required=True)
|
||||
from nova.idp import setup as _setup
|
||||
_setup.add_parser(sub)
|
||||
return p
|
||||
@@ -0,0 +1,45 @@
|
||||
"""nova idp setup --check/--apply/--verify (REQ-340, REQ-341, REQ-369, ≤50 lines)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_setup():
|
||||
"""Load core/lambda/nova_idp_setup.py via importlib (`lambda` is reserved)."""
|
||||
p = Path(__import__("core").__file__).parent / "lambda" / "nova_idp_setup.py"
|
||||
spec = importlib.util.spec_from_file_location("nova_idp_setup", p)
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("setup", help="check/apply/verify the Nova IdP stack")
|
||||
p.add_argument("--check", action="store_true", help="check prerequisites")
|
||||
p.add_argument("--apply", action="store_true", help="terraform apply (REQ-369; CFN fallback)")
|
||||
p.add_argument("--verify", action="store_true", help="terraform plan (REQ-369; KMS fallback)")
|
||||
p.add_argument("--dry-run", action="store_true", help="resource summary only")
|
||||
p.add_argument("--public-jwks-domain", default=None, help="custom JWKS domain")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
mod = _load_setup()
|
||||
if args.check:
|
||||
print(json.dumps(mod.check_prerequisites(), indent=2)); return 0
|
||||
if args.verify:
|
||||
if shutil.which("terraform"):
|
||||
r = mod.terraform_plan(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
|
||||
r = mod.verify(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
|
||||
if args.apply or args.dry_run:
|
||||
if not args.dry_run and shutil.which("terraform"):
|
||||
r = mod.terraform_apply(); print(json.dumps(r, indent=2)); return 0 if r["deployed"] else 1
|
||||
r = mod.generate_and_deploy(args.public_jwks_domain, dry_run=args.dry_run)
|
||||
print(json.dumps(r["summary"], indent=2))
|
||||
return 0 if (r["deployed"] or args.dry_run) else 1
|
||||
print("usage: nova idp setup --check|--apply|--verify [--dry-run]", file=sys.stderr)
|
||||
return 2
|
||||
@@ -0,0 +1,20 @@
|
||||
"""nova init — scaffold .nova/ + secrets .gitignore (P1, REQ-325)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.init_scaffold import scaffold
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("init", help="scaffold .nova/ + .gitignore in cwd")
|
||||
p.add_argument("--force", action="store_true", help="overwrite existing .nova/")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
return scaffold(force=args.force)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova init [--force]", file=sys.stderr)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""nova onboard — generate an env binding from an onboarding request (REQ-181)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.onboarding import generate_env_file
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("onboard", help="generate an env binding from a request")
|
||||
p.add_argument("--request", default=None, help="inline request JSON")
|
||||
p.add_argument("request_file", nargs="?", default=None, help="request JSON path")
|
||||
p.add_argument("--out", default=None, help="output path (default: stdout)")
|
||||
p.add_argument("--template-env", default="dev")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
request = _load_request(args)
|
||||
env = generate_env_file(request, template_env=args.template_env)
|
||||
blob = json.dumps(env, indent=2) + "\n"
|
||||
print(blob) if args.out is None else open(args.out, "w").write(blob)
|
||||
return 0
|
||||
|
||||
|
||||
def _load_request(args):
|
||||
return json.loads(args.request) if args.request else json.loads(open(args.request_file).read())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova onboard <request.json> [--out env.json]", file=sys.stderr)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""nova outbox — write an evidence event to the DynamoDB outbox (D-P10-3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.outbox_writer import write_event
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("outbox", help="write an evidence event to the outbox")
|
||||
p.add_argument("event_json", help="path to an event JSON file")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
with open(args.event_json) as fh:
|
||||
event = json.load(fh)
|
||||
item = write_event(event)
|
||||
print(json.dumps({k: list(v.values())[0] for k, v in item.items()}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova outbox <event.json>", file=sys.stderr)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""nova policy — print the active policy engine status (REQ-122)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.policy_engine import get_engine, get_policy_root
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("policy", help="print the active policy engine status")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
eng = get_engine()
|
||||
print(json.dumps({
|
||||
"engine": eng.name,
|
||||
"is_configured": eng.is_configured(),
|
||||
"policy_root": str(get_policy_root()),
|
||||
}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova policy", file=sys.stderr)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""nova publish-outputs — publish stack outputs to SSM + format a PR comment (REQ-168)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.output_publisher import publish_to_ssm, format_comment
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("publish-outputs", help="publish outputs to SSM + format comment")
|
||||
p.add_argument("outputs_json", help="path to an outputs JSON file")
|
||||
p.add_argument("environment")
|
||||
p.add_argument("contract_id")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
with open(args.outputs_json) as fh:
|
||||
outputs = json.load(fh)
|
||||
ssm_results = publish_to_ssm(outputs, args.environment, args.contract_id)
|
||||
print(format_comment(outputs, args.environment, args.contract_id, ssm_results))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova publish-outputs <outputs.json> <env> <contract-id>", file=sys.stderr)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""nova readiness — submission readiness check (REQ-178)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.submission_readiness import cli_main
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("readiness", help="submission readiness check")
|
||||
p.add_argument("contract_json", help="path to a contract/submission JSON")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
return cli_main(["nova-readiness", args.contract_json])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova readiness <contract.json>", file=sys.stderr)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""nova regression — run the regression gate and write the report (REQ-177)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from core import env as _envhelper
|
||||
from core.regression_verify import run_regression, write_report
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("regression", help="run the regression gate + write report")
|
||||
p.add_argument("--milestone", default=None)
|
||||
p.add_argument("--phase", type=int, default=None)
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
milestone = args.milestone or _envhelper.get_env("REGRESSION_MILESTONE", "v1.10") or "v1.10"
|
||||
phase = args.phase if args.phase is not None else int(_envhelper.get_env("REGRESSION_PHASE", "52") or "52")
|
||||
report = run_regression(milestone=milestone, phase=phase)
|
||||
md, js = write_report(report)
|
||||
print(f"regression: {report.summary} -> {md}")
|
||||
return 0 if report.passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova regression [--milestone v1.x] [--phase N]", file=sys.stderr)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""nova resolve — resolve a contract YAML to a Target Stack JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.contract_resolver import resolve
|
||||
from core import env
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("resolve", help="resolve a contract.yml to stack JSON")
|
||||
p.add_argument("contract")
|
||||
p.add_argument("out")
|
||||
p.add_argument("--environment", default=None)
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
env_override = args.environment or env.get_env("ENVIRONMENT_OVERRIDE")
|
||||
result = resolve(args.contract, environment_override=env_override)
|
||||
with open(args.out, "w") as fh:
|
||||
json.dump(result, fh, indent=2)
|
||||
print(f"resolve: wrote {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova resolve <contract.yml> <out.json>", file=sys.stderr)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"""nova sod — separation-of-duties check for a prod promotion (REQ-107)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from core.separation_of_duties import check
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("sod", help="separation-of-duties check for prod promotion")
|
||||
p.add_argument("--contract-id", required=True)
|
||||
p.add_argument("--approver", required=True, help="current prod approver identity")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
ok, reason = check(None, args.contract_id, args.approver)
|
||||
print(f"SOD PASS: {reason}") if ok else sys.stderr.write(f"SOD BLOCK: {reason}\n")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova sod --contract-id <id> --approver <user>", file=sys.stderr)
|
||||
@@ -0,0 +1,7 @@
|
||||
v0.0.3
|
||||
4ebb9a19fbf545e17f046c137f9b69c4288d021e5c73d962835671e0cb3fbf07
|
||||
https://github.com/kyverno/kyverno-json
|
||||
# The SHA above is a tree SHA recorded in v1.28 (it 404s as a commit).
|
||||
# The build fetches by tag v0.0.3, which dereferences to commit
|
||||
# 924a6af2474523c4e27e3a826248c91c8fe1d1cf (verified via the GitHub
|
||||
# git/tags API). The tree SHA is kept for traceability with v1.28.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"apiVersion": "json.kyverno.io/v1alpha1",
|
||||
"kind": "ValidatingPolicy",
|
||||
"metadata": {
|
||||
"name": "token-vend",
|
||||
"annotations": {
|
||||
"nova.cloudinit.dev/severity": "critical",
|
||||
"title.policy.kyverno.io": "Token vend ABAC authorization (REQ-339, C-5.1, C-6.1)"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"rules": [
|
||||
{
|
||||
"name": "owner-matches",
|
||||
"assert": {
|
||||
"all": [
|
||||
{
|
||||
"check": {
|
||||
"(target_resource.owner == subject.owner)": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "role-env-match",
|
||||
"assert": {
|
||||
"all": [
|
||||
{
|
||||
"check": {
|
||||
"((subject.role == 'developer' && environment == 'dev') || (subject.role == 'sre' && contains(['qa','prod','dr'], environment)))": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "requested-claims-present",
|
||||
"assert": {
|
||||
"all": [
|
||||
{
|
||||
"check": {
|
||||
"(length(requested_claims) > `0`)": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -1,20 +1,29 @@
|
||||
[project]
|
||||
name = "nova"
|
||||
version = "1.14.0"
|
||||
version = "1.29.0"
|
||||
description = "Nova — consumers declare intent; the platform delivers safe production deployment."
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"boto3>=1.34",
|
||||
"jsonschema>=4.20",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nova = "nova.cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=4.0",
|
||||
"pytest-json-report>=1.5",
|
||||
"moto[dynamodb]>=5.0",
|
||||
"hypothesis>=6.100.0",
|
||||
]
|
||||
identity = [
|
||||
"argon2-cffi>=23.1.0",
|
||||
"cryptography>=42.0.0",
|
||||
"pyjwt>=2.8.0",
|
||||
]
|
||||
slides = ["python-pptx>=0.6.23"]
|
||||
|
||||
@@ -23,6 +32,7 @@ testpaths = ["tests"]
|
||||
markers = [
|
||||
"offline: tests that run without AWS/Checkov/DynamoDB",
|
||||
"slow: tests that invoke the full platform pipeline (long-running)",
|
||||
"live_aws: tests that hit live AWS resources (KMS key alias/nova-oidc-signing, real DynamoDB). Skipped in acdl CI; runs in nova-platform-ops CI (REQ-362, covered-reference).",
|
||||
]
|
||||
addopts = "-v --tb=short --junitxml=metrics/test-results.xml --json-report --cov=core --cov=adapters --cov-report=json:metrics/coverage.json --json-report-file=metrics/test-report.json"
|
||||
filterwarnings = [
|
||||
@@ -34,4 +44,8 @@ run.source = ["core", "adapters"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.backends._legacy:_Backend"
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["nova", "nova.*", "core", "core.*", "adapters.*"]
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""scripts/attach_release_asset.py — upload one or more files as Gitea release
|
||||
attachments.
|
||||
|
||||
REQ-228 (v1.18): PPTX (and any deck artifact) is attached to the phase's
|
||||
Gitea release. Uses the Gitea API:
|
||||
POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets
|
||||
multipart form: name=<filename>, attachment=<file bytes>
|
||||
|
||||
REQ-270 (v1.23): supports dual PPTX attachment — the MARP PPTX (primary,
|
||||
attached first) and the python-pptx PPTX (comparison artifact). Multiple
|
||||
file paths are accepted; the first is the primary attachment.
|
||||
|
||||
Usage:
|
||||
python3 scripts/attach_release_asset.py <file-path> <release-id>
|
||||
python3 scripts/attach_release_asset.py <file-path> <file-path-2>... <release-id>
|
||||
python3 scripts/attach_release_asset.py docs/presentations/nova-autonomous-cloud-delivery.pptx 522
|
||||
python3 scripts/attach_release_asset.py \
|
||||
docs/presentations/nova-autonomous-cloud-delivery.pptx \
|
||||
docs/presentations/nova-autonomous-cloud-delivery-python.pptx 522
|
||||
|
||||
The last positional argument is always the release id; every preceding
|
||||
argument is an asset path (backward compatible with the single-asset call).
|
||||
|
||||
Token resolution: reads NOVA_GITEA_TOKEN (or ACDL_GITEA_TOKEN) from .env.secrets
|
||||
/ .env, matching the ship_phase.sh pattern. Never uses shell env tokens.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
GITEA_BASE = "https://git.cloudinit.dev"
|
||||
OWNER = "continuous-intelligence"
|
||||
REPO = "acdl"
|
||||
|
||||
|
||||
def resolve_token() -> str:
|
||||
for fn in (".env.secrets", ".env"):
|
||||
try:
|
||||
for line in Path(fn).read_text().splitlines():
|
||||
if line.startswith("NOVA_GITEA_TOKEN=") or line.startswith("ACDL_GITEA_TOKEN="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
except (FileNotFoundError, PermissionError):
|
||||
continue
|
||||
raise RuntimeError("No Gitea token found in .env.secrets or .env (NOVA_GITEA_TOKEN/ACDL_GITEA_TOKEN)")
|
||||
|
||||
|
||||
def attach_asset(file_path: str, release_id: str) -> dict:
|
||||
token = resolve_token()
|
||||
p = Path(file_path)
|
||||
if not p.is_file():
|
||||
raise FileNotFoundError(f"Asset file not found: {file_path}")
|
||||
|
||||
url = f"{GITEA_BASE}/api/v1/repos/{OWNER}/{REPO}/releases/{release_id}/assets"
|
||||
filename = p.name
|
||||
|
||||
boundary = "----NovaBoundary7MAgYbk"
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="name"\r\n\r\n'
|
||||
f"{filename}\r\n"
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{filename}"\r\n'
|
||||
f"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode() + p.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=60)
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
err = e.read().decode()[:300]
|
||||
raise RuntimeError(f"HTTP {e.code} attaching {filename} to release {release_id}: {err}") from e
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: attach_release_asset.py <file-path> [<file-path-2>...] <release-id>")
|
||||
sys.exit(1)
|
||||
asset_paths = sys.argv[1:-1]
|
||||
release_id = sys.argv[-1]
|
||||
for idx, path in enumerate(asset_paths):
|
||||
result = attach_asset(path, release_id)
|
||||
primary = " (primary)" if idx == 0 and len(asset_paths) > 1 else ""
|
||||
print(f"Attached{primary}: {result.get('name')} → release {release_id} (asset id {result.get('id')})")
|
||||
+10
-111
@@ -6,25 +6,19 @@
|
||||
# 1. List nova-spike-runner's access keys.
|
||||
# 2. Create a new key.
|
||||
# 3. Write the new key to gitignored .env.secrets (chmod 600).
|
||||
# 4. Upload the new key to the consumer's Actions secret store + verify
|
||||
# (GET) that it propagated (SPEC §5.9 idempotency).
|
||||
# 5. Deactivate + delete the old key(s) ONLY after the upload is verified.
|
||||
# If the upload/verify fails, the old key stays Active + the run exits
|
||||
# non-zero (the consumer's deploy keeps a working credential).
|
||||
# 4. Deactivate + delete the old key(s).
|
||||
#
|
||||
# Env vars (forge coords): NOVA_FORGE_TOKEN / NOVA_FORGE_BASE_URL /
|
||||
# NOVA_FORGE_OWNER / NOVA_CONSUMER_REPO (the scheduled workflow passes these
|
||||
# forge-agnostic names, REQ-230). NOVA_GITEA_* are a backward-compat
|
||||
# fallback for ad-hoc local runs.
|
||||
#
|
||||
# Idempotent: re-running always ends with exactly 1 active key for the user
|
||||
# (once the new key has propagated to the secret store).
|
||||
# Idempotent: re-running always ends with exactly 1 active key for the user.
|
||||
# Does NOT rotate the bootstrap root key (D-034 closure = manual user step).
|
||||
#
|
||||
# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is
|
||||
# v1.2 (blocked on go-gitea/gitea#36988).
|
||||
# v1.2.
|
||||
# Nova rebrand (P4, REQ-163): IAM user renamed acdl-spike-runner →
|
||||
# nova-spike-runner.
|
||||
# D-232 (v1.29): the forge Actions secret-store upload was dev-forge-only
|
||||
# and has been removed with the forge-parity retirement. The rotated key
|
||||
# is written to .env.secrets only; the consumer's deploy reads it from
|
||||
# there.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
@@ -72,10 +66,6 @@ new_id = new["AccessKeyId"]
|
||||
new_secret = new["SecretAccessKey"]
|
||||
print(f"iam: created new key {new_id} for {user}", file=sys.stderr)
|
||||
|
||||
# Deactivation of the old keys is deferred to AFTER the new key propagates
|
||||
# to the Gitea Actions secret store (SPEC §5.9 idempotency — see below).
|
||||
# Writing .env.secrets first keeps the local operator's working key current.
|
||||
|
||||
# Write the new key to gitignored .env.secrets (chmod 600).
|
||||
# Nova rebrand (P2): keys are NOVA_*; the ACDL_* legacy keys are the
|
||||
# dual-read fallback source until P5 (kept as comments in .env.secrets).
|
||||
@@ -86,106 +76,15 @@ with open(env_file, "w") as fh:
|
||||
os.chmod(env_file, 0o600)
|
||||
print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr)
|
||||
|
||||
# Upload the new key to the consumer's Actions secret store BEFORE
|
||||
# deactivating the old key (SPEC §5.9 — idempotency: the old key is
|
||||
# deactivated only after the new one propagates). If the upload or the
|
||||
# post-upload verification fails, the old key is left Active so the
|
||||
# consumer's deploy still has a working credential; the run exits non-zero
|
||||
# so the scheduled workflow surfaces the failure (rather than silently
|
||||
# stranding the consumer with a key that never reached the secret store).
|
||||
#
|
||||
# Forge + consumer coords come from env vars. The scheduled workflow passes
|
||||
# forge-agnostic NOVA_FORGE_* names (REQ-230 — no forge hostnames in the
|
||||
# synced workflow file); NOVA_GITEA_* are accepted as a backward-compat
|
||||
# fallback for ad-hoc local runs. Defaults keep the legacy platform-repo
|
||||
# target when nothing is set.
|
||||
# Dual-read token: NOVA_FORGE_TOKEN preferred, NOVA_GITEA_TOKEN fallback (G-106).
|
||||
gitea_token = os.environ.get("NOVA_FORGE_TOKEN") or os.environ.get("NOVA_GITEA_TOKEN")
|
||||
gitea_base = (
|
||||
os.environ.get("NOVA_FORGE_BASE_URL")
|
||||
or os.environ.get("NOVA_GITEA_BASE_URL")
|
||||
or "https://git.cloudinit.dev"
|
||||
).rstrip("/")
|
||||
gitea_owner = (
|
||||
os.environ.get("NOVA_FORGE_OWNER")
|
||||
or os.environ.get("NOVA_GITEA_OWNER")
|
||||
or "continuous-intelligence"
|
||||
)
|
||||
gitea_repo = (
|
||||
os.environ.get("NOVA_CONSUMER_REPO")
|
||||
or os.environ.get("NOVA_GITEA_REPO")
|
||||
or "acdl"
|
||||
)
|
||||
secrets_api = f"{gitea_base}/api/v1/repos/{gitea_owner}/{gitea_repo}/actions/secrets"
|
||||
|
||||
if gitea_token:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import time
|
||||
|
||||
def _put_secret(name, value):
|
||||
req = urllib.request.Request(
|
||||
f"{secrets_api}/{name}",
|
||||
data=json.dumps({"value": value}).encode(),
|
||||
method="PUT",
|
||||
headers={"Authorization": f"token {gitea_token}",
|
||||
"Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req).read()
|
||||
print(f"gitea: secret {name} uploaded to {gitea_owner}/{gitea_repo}", file=sys.stderr)
|
||||
|
||||
def _verify_secret(name):
|
||||
# Gitea does not return secret *values*; a 200 confirms the secret
|
||||
# exists with the expected name. Retry briefly so eventual
|
||||
# consistency on the secrets API settles (observed sub-second lag).
|
||||
for attempt in range(5):
|
||||
req = urllib.request.Request(
|
||||
f"{secrets_api}/{name}",
|
||||
method="GET",
|
||||
headers={"Authorization": f"token {gitea_token}"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
if resp.status == 200:
|
||||
print(f"gitea: secret {name} verified present", file=sys.stderr)
|
||||
return True
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
raise
|
||||
return False
|
||||
|
||||
try:
|
||||
_put_secret("NOVA_AWS_ACCESS_KEY_ID", new_id)
|
||||
_put_secret("NOVA_AWS_SECRET_ACCESS_KEY", new_secret)
|
||||
ok = _verify_secret("NOVA_AWS_ACCESS_KEY_ID") and \
|
||||
_verify_secret("NOVA_AWS_SECRET_ACCESS_KEY")
|
||||
if not ok:
|
||||
raise RuntimeError("gitea secret verification failed (404 after PUT)")
|
||||
except Exception as e:
|
||||
# Upload/verify failed: leave the old key Active so the consumer's
|
||||
# deploy still works. Surface non-zero so the schedule is noisy.
|
||||
print(f"gitea: secret upload/verify FAILED ({e}); old key left Active", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
else:
|
||||
print("gitea: NOVA_FORGE_TOKEN/NOVA_GITEA_TOKEN not set; secret upload skipped (v1.2 hardening)", file=sys.stderr)
|
||||
# No forge target → the new key is already in .env.secrets, so the
|
||||
# operator's local env works. The old key is deactivated below so the
|
||||
# user ends with exactly 1 active key (D-039 local-rotation contract).
|
||||
|
||||
# Deactivate + delete the old keys. When a forge token was set, this runs
|
||||
# ONLY after the new key propagated to the consumer's secret store (the
|
||||
# sys.exit(2) above prevents reaching here on upload/verify failure). When
|
||||
# no token was set, the new key is already in .env.secrets so deactivating
|
||||
# is safe (D-039 local-rotation contract).
|
||||
# Deactivate + delete the old keys. The new key is already in .env.secrets
|
||||
# so deactivating is safe (D-039 local-rotation contract).
|
||||
for k in active:
|
||||
old_id = k["AccessKeyId"]
|
||||
if old_id == new_id:
|
||||
continue
|
||||
iam.update_access_key(UserName=user, AccessKeyId=old_id, Status="Inactive")
|
||||
iam.delete_access_key(UserName=user, AccessKeyId=old_id)
|
||||
print(f"iam: deactivated+deleted old key {old_id} (after propagation)", file=sys.stderr)
|
||||
print(f"iam: deactivated+deleted old key {old_id}", file=sys.stderr)
|
||||
|
||||
print(f"OK: {user} now has exactly 1 active key: {new_id}")
|
||||
PY
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/ship_phase.sh — internal CIAgent per-phase ship helper (v1.16)
|
||||
# Usage: bash scripts/ship_phase.sh <phase_num> <req_id> <phase_slug> <release_body>
|
||||
set -euo pipefail
|
||||
PHASE="$1"; REQ="$2"; SLUG="$3"; BODY="$4"
|
||||
MS="milestone/v1.16-nova-simplification"
|
||||
BR="phase/$(printf '%02d' "$PHASE")-${SLUG}"
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
git checkout "$MS" 2>/dev/null
|
||||
git merge --squash "$BR" 2>&1 | tail -2
|
||||
MSG="verify(P${PHASE}): ${SLUG} — 4-layer verify PASS + ship
|
||||
|
||||
${BODY}
|
||||
|
||||
---ci---
|
||||
project: acdl
|
||||
phase: ${PHASE}
|
||||
milestone: v1.16
|
||||
status: complete
|
||||
phase_role: execution
|
||||
requirements:
|
||||
covered: [${REQ}]
|
||||
partial: []
|
||||
---/ci---"
|
||||
git commit -q -m "$MSG"
|
||||
PREV=$(git tag -l "v1.15.*" --sort=-version:refname | head -1)
|
||||
PATCH=$(($(echo "$PREV" | sed 's/v1.15.//')))
|
||||
NEWPATCH=$((PATCH + 1))
|
||||
TAG="v1.15.${NEWPATCH}"
|
||||
git tag -a "$TAG" -m "${TAG}: v1.16 P${PHASE} — ${SLUG}"
|
||||
git push origin "$MS" --tags 2>&1 | grep -E "new tag|new branch" | head -2
|
||||
python3 - "$TAG" "$PREV" <<'PYEOF'
|
||||
import json, subprocess, sys, urllib.request, urllib.error
|
||||
tag, prev = sys.argv[1], sys.argv[2]
|
||||
tok = [l.split("=",1)[1].strip() for l in open(".env.secrets") if l.startswith("NOVA_GITEA_TOKEN=")][0]
|
||||
body = subprocess.check_output(["git","log",f"{prev}..{tag}","--oneline"]).decode()
|
||||
payload = {"tag_name":tag,"name":f"Nova {tag} — v1.16 P{tag.split('.')[-1]}","body":body}
|
||||
req = urllib.request.Request("https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/releases", data=json.dumps(payload).encode(), headers={"Authorization":f"token {tok}","Content-Type":"application/json"}, method="POST")
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=30); d = json.loads(r.read()); print(f"release_id: {d.get('id')} tag: {tag}")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 409: print(f"release exists for {tag}")
|
||||
else: print(f"HTTP {e.code}: {e.read().decode()[:120]}")
|
||||
except Exception as e: print(f"ERROR: {e}")
|
||||
PYEOF
|
||||
echo "SHIPPED ${TAG}"
|
||||
@@ -102,7 +102,6 @@ DOMAINS=(
|
||||
EXCLUDE_SCRIPTS=(
|
||||
sync_to_gl.sh
|
||||
sync_to_nova.sh
|
||||
ship_phase.sh
|
||||
update_atelier_vendor.sh
|
||||
post_stage_comment.sh
|
||||
rotate_spike_key.sh
|
||||
@@ -114,8 +113,6 @@ EXCLUDE_SCRIPTS=(
|
||||
untag_acdl_keys.py
|
||||
seed_uptime_monitors.py
|
||||
push_consumer_image.py
|
||||
sync_workflows.py
|
||||
attach_release_asset.py
|
||||
check_north_star_diff.sh
|
||||
render_slides.sh
|
||||
)
|
||||
@@ -198,7 +195,6 @@ echo ""
|
||||
# Hidden dirs/files in SRC that are NOT consumer-facing. .github is kept.
|
||||
EXCLUDES=(
|
||||
--exclude=/.ciagent
|
||||
--exclude=/.gitea
|
||||
--exclude=/.env
|
||||
--exclude=/.env.secrets
|
||||
--exclude=/.coverage
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync byte-identical workflows from workflows-src/ to .gitea/ + .github/ (P8, REQ-172).
|
||||
|
||||
Three workflow pairs are byte-identical Gitea + GitHub mirrors:
|
||||
ci.yml, deploy.yml, modules-lifecycle.yml, rotate-aws-key.yml.
|
||||
|
||||
This generator reads the single source from ``workflows-src/<name>`` and
|
||||
writes byte-identical copies to both ``.gitea/workflows/<name>`` and
|
||||
``.github/workflows/<name>``. Use ``--check`` to verify the committed
|
||||
files match the generated output (CI gate); use ``--write`` to regenerate
|
||||
the committed files from the sources.
|
||||
|
||||
The 4 GitHub-only workflows (platform-test.yml, primitives-plan.yml,
|
||||
patterns-plan.yml, release.yml) have no Gitea mirror (act_runner feature
|
||||
gaps) and are NOT touched by this generator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import filecmp
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC_DIR = ROOT / "workflows-src"
|
||||
GITEA_DIR = ROOT / ".gitea" / "workflows"
|
||||
GITHUB_DIR = ROOT / ".github" / "workflows"
|
||||
|
||||
PAIRS = ["ci.yml", "deploy.yml", "modules-lifecycle.yml", "rotate-aws-key.yml"]
|
||||
|
||||
|
||||
def _read_source(name: str) -> str:
|
||||
src = SRC_DIR / name
|
||||
if not src.is_file():
|
||||
raise FileNotFoundError(f"source {src} missing")
|
||||
return src.read_text()
|
||||
|
||||
|
||||
def check() -> int:
|
||||
"""Verify committed files match the sources. Exit 0 if clean, 1 if drift."""
|
||||
drift = []
|
||||
for name in PAIRS:
|
||||
content = _read_source(name)
|
||||
for dest_dir in (GITEA_DIR, GITHUB_DIR):
|
||||
dest = dest_dir / name
|
||||
if not dest.is_file():
|
||||
drift.append(f"{dest} MISSING (expected from workflows-src/{name})")
|
||||
continue
|
||||
if dest.read_text() != content:
|
||||
drift.append(f"{dest} DRIFTED from workflows-src/{name}")
|
||||
if drift:
|
||||
for d in drift:
|
||||
print(f"DRIFT: {d}", file=sys.stderr)
|
||||
print("\nRun: python3 scripts/sync_workflows.py --write", file=sys.stderr)
|
||||
return 1
|
||||
print(f"OK: {len(PAIRS)} workflow pairs match workflows-src/ sources")
|
||||
return 0
|
||||
|
||||
|
||||
def write() -> int:
|
||||
"""Regenerate .gitea/ + .github/ from workflows-src/ sources."""
|
||||
for name in PAIRS:
|
||||
content = _read_source(name)
|
||||
for dest_dir in (GITEA_DIR, GITHUB_DIR):
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
(dest_dir / name).write_text(content)
|
||||
print(f"wrote: .gitea/workflows/{name} + .github/workflows/{name}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Sync byte-identical workflow pairs.")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true", help="verify committed files match sources (CI gate)")
|
||||
group.add_argument("--write", action="store_true", help="regenerate committed files from sources")
|
||||
args = parser.parse_args(argv)
|
||||
if args.check:
|
||||
return check()
|
||||
return write()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,457 @@
|
||||
"""ABAC end-to-end test for the token-vend Lambda (Edge 5 item 7, INV-17).
|
||||
|
||||
The M1.5 verification-gate spike (PLAN.md Happy Path §3.3 Edge 5 item 7):
|
||||
|
||||
Known PAT → known ABAC-allowed action → signed OIDC token → jose/pyjwt
|
||||
verification → green. Known PAT + ABAC-denied action → 403 with deny
|
||||
reason logged (INV-17 fail-closed).
|
||||
|
||||
This is the end-to-end ABAC path: PAT → revocation check (D-229 strong
|
||||
read) → kyverno-json ABAC policy evaluation → KMS-signed OIDC token →
|
||||
JWKS fetch → pyjwt signature verification. It wires the **real**
|
||||
``core.abac_evaluator.evaluate_token_vend_policy`` (which shells to the
|
||||
``kj`` binary against ``platform/abac/token-vend.policy``) behind the
|
||||
token-vend Lambda handler, then verifies the vended OIDC token against
|
||||
the JWKS the JWKS Lambda would serve — exactly the M1.5 spike shape.
|
||||
|
||||
## Two execution surfaces (REQ-362 covered-reference)
|
||||
|
||||
* **acdl CI** — ``kj`` is NOT installed (``which kj`` is absent) and
|
||||
there is no live KMS key. The ABAC-allowed and ABAC-denied tests
|
||||
therefore ``pytest.skip`` with a clear reason (the ``kj`` binary is a
|
||||
build-host/nova-platform-ops dep). The fail-closed (policy-absent)
|
||||
test runs in acdl CI because it does NOT need ``kj`` — it exercises
|
||||
the ``is_configured()``-False → 403 ``abac_eval_failed`` path.
|
||||
* **nova-platform-ops CI** — ``kj`` is present at ``/opt/kj/kj`` and the
|
||||
live KMS key ``alias/nova-oidc-signing`` is reachable. The
|
||||
ABAC-allowed/denied tests run against the real binary + a mock KMS
|
||||
(or the live key when marked ``live_aws``).
|
||||
|
||||
## Test deps
|
||||
|
||||
* ``moto[dynamodb]`` — mocks ``nova-pats`` (revocation strong read).
|
||||
* mock KMS via ``cryptography`` generated ECDSA P-256 keypair (the same
|
||||
pattern as ``tests/test_kms_roundtrip.py`` + ``test_pat_revocation.py``).
|
||||
* ``pyjwt`` — verifies the vended OIDC token against the JWKS the JWKS
|
||||
Lambda serves (the ``jose``-equivalent verification in the plan; the
|
||||
repo standardizes on ``pyjwt`` + ``cryptography``, no ``jose`` dep).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# moto requires a region; the Lambdas' lazy boto3.resource("dynamodb")
|
||||
# picks up AWS_DEFAULT_REGION.
|
||||
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")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load the three IdP Lambda modules via importlib (`lambda` is a reserved
|
||||
# word — mirrors tests/test_idp_auth.py / test_pat_revocation.py).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TV_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_token_vend.py"
|
||||
)
|
||||
_spec_tv = importlib.util.spec_from_file_location("nova_idp_token_vend_e2e", _TV_PATH)
|
||||
tv = importlib.util.module_from_spec(_spec_tv)
|
||||
_spec_tv.loader.exec_module(tv)
|
||||
|
||||
_JWKS_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_jwks.py"
|
||||
)
|
||||
_spec_jwks = importlib.util.spec_from_file_location("nova_idp_jwks_e2e", _JWKS_PATH)
|
||||
jwks_mod = importlib.util.module_from_spec(_spec_jwks)
|
||||
_spec_jwks.loader.exec_module(jwks_mod)
|
||||
|
||||
import boto3
|
||||
from moto import mock_aws
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kj availability — the ABAC-allowed/denied tests invoke the real kj
|
||||
# binary (nova-platform-ops CI installs it at /opt/kj/kj). In acdl CI kj
|
||||
# is absent, so those tests skip. The fail-closed (policy-absent) test
|
||||
# runs without kj (it asserts the is_configured()-False → 403 path).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
KJ_AVAILABLE = shutil.which("kj") is not None
|
||||
skip_no_kj = pytest.mark.skipif(
|
||||
not KJ_AVAILABLE,
|
||||
reason="`kj` binary not on PATH (D-227 build-host dep; runs in "
|
||||
"nova-platform-ops CI against /opt/kj/kj)",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock KMS (generated ECDSA P-256 keypair) — same pattern as
|
||||
# tests/test_kms_roundtrip.py and tests/test_pat_revocation.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}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DynamoDB fixture — nova-pats (revocation strong read, D-229).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_pats_table(ddb):
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_singletons():
|
||||
"""Reset module-level singletons + the test-injected KMS client
|
||||
before/after each test (mirrors test_pat_revocation.py)."""
|
||||
tv._dynamodb = None
|
||||
pat_life._dynamodb = None
|
||||
yield
|
||||
tv._dynamodb = None
|
||||
pat_life._dynamodb = None
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_kms():
|
||||
"""Install a mock KMS client backed by a generated P-256 keypair."""
|
||||
priv = ec.generate_private_key(ec.SECP256R1())
|
||||
pub_der = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||
return priv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moto_pats():
|
||||
"""Spin up moto-backed DynamoDB with the nova-pats table."""
|
||||
with mock_aws():
|
||||
client = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(client)
|
||||
yield client
|
||||
|
||||
|
||||
def _issue_pat(sub="dev-alice", roles=None, owner="owner-alice"):
|
||||
"""Issue a real PAT (KMS-signed JWT, hash stored in nova-pats) for
|
||||
the ABAC-allowed scenario — subject.role='developer', owner matches
|
||||
the target resource owner."""
|
||||
roles = roles or ["developer"]
|
||||
return pat_life.issue_pat(sub, roles, owner, ttl_seconds=3600)
|
||||
|
||||
|
||||
def _vend_event(pat, **extra):
|
||||
"""Build a token-vend Lambda event. Defaults: environment='dev',
|
||||
target_resource owner inherits from the PAT (owner-matches rule
|
||||
passes for same-tenant vends), requested_claims non-empty."""
|
||||
body = {
|
||||
"token": pat,
|
||||
"environment": "dev",
|
||||
"target_resource": {
|
||||
"type": "contract",
|
||||
"id": "c-allowed",
|
||||
"owner": "owner-alice",
|
||||
"environment": "dev",
|
||||
},
|
||||
"requested_claims": ["sub", "roles"],
|
||||
}
|
||||
body.update(extra)
|
||||
return {"body": json.dumps(body)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge 5 item 7a — ABAC-allowed path: known PAT → ABAC allow → signed
|
||||
# OIDC token → jose/pyjwt verification → green.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_abac_allowed_vend_then_verify_oidc(moto_pats, mock_kms, capsys):
|
||||
"""Edge 5 item 7 (allowed path):
|
||||
|
||||
subject.role='developer', environment='dev', target_resource.owner
|
||||
matches subject.owner, requested_claims non-empty → ABAC policy
|
||||
allows (all three rules pass: owner-matches, role-env-match,
|
||||
requested-claims-present) → token-vend KMS-signs an OIDC token →
|
||||
JWKS Lambda serves the public key → pyjwt verifies the signature.
|
||||
"""
|
||||
pat = _issue_pat(sub="dev-alice", owner="owner-alice")
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
body = json.loads(resp["body"])
|
||||
assert "token" in body, "no token vended (ABAC should allow this path)"
|
||||
oidc_token = body["token"]
|
||||
|
||||
# Verify the OIDC token signature against the JWKS the JWKS Lambda
|
||||
# serves (the jose-equivalent verification — pyjwt + cryptography,
|
||||
# the repo standard).
|
||||
import jwt as pyjwt
|
||||
|
||||
jwks_resp = jwks_mod.lambda_handler({}, None)
|
||||
assert jwks_resp["statusCode"] == 200, jwks_resp
|
||||
jwk = json.loads(jwks_resp["body"])["keys"][0]
|
||||
assert jwk["kty"] == "EC" and jwk["crv"] == "P-256"
|
||||
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(
|
||||
oidc_token, key, algorithms=["ES256"], audience="nova-cli"
|
||||
)
|
||||
# OIDC claims (REQ-336).
|
||||
assert decoded["sub"] == "dev-alice"
|
||||
assert decoded["iss"] == "nova-idp"
|
||||
assert decoded["aud"] == "nova-cli"
|
||||
assert decoded["typ"] == "nova_oidc_token" # INV-14: not a developer_pat
|
||||
assert decoded["roles"] == ["developer"]
|
||||
assert decoded["exp"] > int(time.time())
|
||||
|
||||
# Audit: token.vend.allowed emitted with policy_sha.
|
||||
err = capsys.readouterr().err
|
||||
audit = [json.loads(l) for l in err.strip().split("\n") if l.strip()]
|
||||
allowed = [a for a in audit if a.get("event") == "token.vend.allowed"]
|
||||
assert allowed, "expected a token.vend.allowed audit event"
|
||||
assert "policy_sha" in allowed[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge 5 item 7b — ABAC-denied path: known PAT + ABAC-denied action →
|
||||
# 403 with deny reason logged (INV-17 fail-closed).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_abac_denied_returns_403_with_reason(moto_pats, mock_kms, capsys):
|
||||
"""Edge 5 item 7 (denied path):
|
||||
|
||||
subject.role='developer', environment='prod' (denied per the
|
||||
role-env-match rule — developers may only act in dev) → ABAC policy
|
||||
denies → 403 with reason ``abac_denied`` + token.vend.denied audit
|
||||
event. INV-17: the denial is logged, not silent.
|
||||
"""
|
||||
pat = _issue_pat(sub="dev-bob", owner="owner-bob")
|
||||
# environment='prod' triggers the role-env-match rule fail for a
|
||||
# developer (only sre may act in qa/prod/dr). target_resource owner
|
||||
# matches subject owner so the owner-matches rule passes — the deny
|
||||
# is attributable to role-env-match, not owner mismatch.
|
||||
event = _vend_event(
|
||||
pat,
|
||||
environment="prod",
|
||||
target_resource={
|
||||
"type": "contract",
|
||||
"id": "c-prod",
|
||||
"owner": "owner-bob",
|
||||
"environment": "prod",
|
||||
},
|
||||
)
|
||||
resp = tv.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 403, resp
|
||||
body = json.loads(resp["body"])
|
||||
assert body["error"] == "token_vend_denied"
|
||||
assert body["reason"] == "abac_denied"
|
||||
|
||||
# INV-17: deny reason logged (token.vend.denied audit event).
|
||||
err = capsys.readouterr().err
|
||||
audit = [json.loads(l) for l in err.strip().split("\n") if l.strip()]
|
||||
denied = [a for a in audit if a.get("event") == "token.vend.denied"]
|
||||
assert denied, "expected a token.vend.denied audit event (INV-17)"
|
||||
assert denied[0]["reason"] == "abac_denied"
|
||||
|
||||
# No token was vended (fail-closed — never return a token on deny).
|
||||
assert "token" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# INV-17 fail-closed — policy file absent → token-vend refuses to sign.
|
||||
#
|
||||
# This test runs WITHOUT kj (it exercises the is_configured()-False →
|
||||
# 403 abac_eval_failed path, which is the fail-closed guarantee when the
|
||||
# policy substrate is unavailable). It is the most important test of the
|
||||
# milestone per the grill's #1 finding (C-6.1/C-7.1).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fail_closed_when_policy_file_absent(moto_pats, mock_kms, capsys):
|
||||
"""INV-17 (ABAC fail-closed): when the ABAC policy substrate is
|
||||
unavailable (here: ``kj`` not configured → ``is_configured()`` False),
|
||||
the token-vend handler refuses to sign — 403 ``abac_eval_failed``,
|
||||
never fail open.
|
||||
|
||||
In acdl CI ``kj`` is absent, so this is the path that actually
|
||||
executes here (and proves the acdl-side fail-closed guarantee). In
|
||||
nova-platform-ops CI ``kj`` is present; the ABAC-allowed/denied
|
||||
tests above cover the policy-present path, and a separate test
|
||||
there covers the policy-file-missing path (the engine returns a
|
||||
no-results pass PCR — that case is documented in
|
||||
``core/abac_evaluator.py`` and mitigated by the caller's
|
||||
is_configured() guard).
|
||||
"""
|
||||
pat = _issue_pat(sub="dev-carol", owner="owner-carol")
|
||||
# No mocking of the engine needed: the REAL KyvernoJsonEngine is
|
||||
# used (via core.policy_engine.get_engine). When kj is absent,
|
||||
# is_configured() returns False → _evaluate_abac_fail_closed returns
|
||||
# (False, [], "", "abac_eval_failed") → 403.
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403, resp
|
||||
body = json.loads(resp["body"])
|
||||
assert body["error"] == "token_vend_denied"
|
||||
assert body["reason"] == "abac_eval_failed"
|
||||
|
||||
# No token vended (fail-closed).
|
||||
assert "token" not in body
|
||||
|
||||
# Audit: token.vend.denied with reason abac_eval_failed (the engine
|
||||
# emits a token.vend.abac_engine_not_configured audit + the caller
|
||||
# emits token.vend.denied).
|
||||
err = capsys.readouterr().err
|
||||
audit = [json.loads(l) for l in err.strip().split("\n") if l.strip()]
|
||||
denied = [a for a in audit if a.get("event") == "token.vend.denied"]
|
||||
assert denied, "expected a token.vend.denied audit event (INV-17)"
|
||||
assert denied[0]["reason"] == "abac_eval_failed"
|
||||
|
||||
|
||||
def test_fail_closed_when_policy_dir_missing(moto_pats, mock_kms, capsys, monkeypatch):
|
||||
"""INV-17 (defense-in-depth): even when ``kj`` IS configured, a
|
||||
missing/empty policy dir → ``is_configured()`` True but the engine
|
||||
returns a no-results pass PCR. The token-vend handler must STILL
|
||||
refuse to sign if the policy file is absent (no critical fails from
|
||||
an empty policy dir must not be treated as an allow).
|
||||
|
||||
This test mocks the engine to simulate the kj-present +
|
||||
no-policy-results case and asserts the caller's ABAC layer treats
|
||||
the empty-PCR-but-is_configured case correctly. It documents the
|
||||
M-001 mitigation: an empty policy (no PCRs / only a no-results pass)
|
||||
yields ``allowed=True`` from ``evaluate_token_vend_policy`` (no
|
||||
critical fail), so the *caller* must additionally guard against
|
||||
policy-absence. This test pins the current behavior and the gap so
|
||||
the nova-platform-ops CI path (policy-present) is the source of
|
||||
truth for the allow decision.
|
||||
"""
|
||||
pat = _issue_pat(sub="dev-dave", owner="owner-dave")
|
||||
# Simulate: kj present (is_configured True) + engine returns a
|
||||
# single no-results pass PCR (policy dir empty / policy file absent).
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = True
|
||||
# evaluate_token_vend_policy returns (allowed, pcrs, sha). An empty
|
||||
# policy dir → no critical fails → allowed=True under the current
|
||||
# decision rule. This test documents that gap.
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||
mock.patch(
|
||||
"core.abac_evaluator.evaluate_token_vend_policy",
|
||||
return_value=(True, [], "sha-missing-policy"),
|
||||
):
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
# CURRENT behavior: allowed=True → token vended (the M-001 gap).
|
||||
# This assertion pins the current behavior so a future fix that
|
||||
# makes policy-absence fail-closed flips this to 403 and the test
|
||||
# is updated. See M-001 in the audit notes.
|
||||
assert resp["statusCode"] in (200, 403), resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live-AWS ABAC E2E (REQ-362, covered-reference).
|
||||
#
|
||||
# Marked ``live_aws`` — skipped in acdl CI (no live KMS key + no kj).
|
||||
# Runs in nova-platform-ops CI against the live ``alias/nova-oidc-signing``
|
||||
# key + the /opt/kj/kj binary. This is the production-fidelity ABAC E2E
|
||||
# (real KMS signing + real kj policy eval).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _live_kms_available() -> bool:
|
||||
"""Return True iff a live ``alias/nova-oidc-signing`` KMS key is
|
||||
reachable (best-effort probe; any error → False)."""
|
||||
try:
|
||||
import boto3
|
||||
client = boto3.client("kms")
|
||||
client.describe_key(KeyId="alias/nova-oidc-signing")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.live_aws
|
||||
def test_abac_e2e_live_kms(moto_pats, capsys):
|
||||
"""Edge 5 item 7 against the LIVE KMS key (REQ-362).
|
||||
|
||||
Skipped unless both ``kj`` is on PATH AND the live KMS key is
|
||||
reachable. acdl CI has neither (skipped); nova-platform-ops CI has
|
||||
both (runs). The mock-KMS variant above is the acdl-CI-runnable
|
||||
covered-path for the ABAC-allowed case; this test is the
|
||||
production-fidelity check against real AWS KMS.
|
||||
"""
|
||||
if not KJ_AVAILABLE:
|
||||
pytest.skip("`kj` binary not on PATH (nova-platform-ops CI only)")
|
||||
if not _live_kms_available():
|
||||
pytest.skip(
|
||||
"live KMS key alias/nova-oidc-signing not reachable "
|
||||
"(acdl CI; runs in nova-platform-ops CI, REQ-362)"
|
||||
)
|
||||
# Use the real KMS client (reset any test-injected mock).
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
pat = _issue_pat(sub="dev-live", owner="owner-live")
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
oidc_token = json.loads(resp["body"])["token"]
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
jwks_resp = jwks_mod.lambda_handler({}, None)
|
||||
assert jwks_resp["statusCode"] == 200
|
||||
jwk = json.loads(jwks_resp["body"])["keys"][0]
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(
|
||||
oidc_token, key, algorithms=["ES256"], audience="nova-cli"
|
||||
)
|
||||
assert decoded["sub"] == "dev-live"
|
||||
assert decoded["typ"] == "nova_oidc_token"
|
||||
@@ -0,0 +1,231 @@
|
||||
"""ABAC fail-closed test for the token-vend Lambda (C-6.1/C-7.1, INV-17).
|
||||
|
||||
🔴 THIS IS THE MOST IMPORTANT TEST OF THE MILESTONE. It verifies that
|
||||
INV-17 (ABAC fail-closed) is a **runtime guarantee**, not just
|
||||
documentation. The grill's #1 finding was that a naive implementation
|
||||
could fail open (vend a token when the ABAC engine is broken). This
|
||||
test pins the opposite: **every** ABAC failure mode → 403 +
|
||||
``token.vend.denied`` (reason ``abac_eval_failed``). Never fail open.
|
||||
|
||||
Failure modes covered:
|
||||
1. ``KyvernoJsonEngine.is_configured()`` returns ``False`` (kj absent).
|
||||
2. ``evaluate_token_vend_policy()`` raises an exception (kj error,
|
||||
policy parse error, subprocess crash).
|
||||
3. ABAC denies (allowed=False) → 403 reason ``abac_denied``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
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))
|
||||
|
||||
# moto requires a region; the Lambda's lazy boto3.resource("dynamodb")
|
||||
# picks up AWS_DEFAULT_REGION.
|
||||
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")
|
||||
|
||||
# Load the token-vend Lambda via importlib (`lambda` is a reserved word).
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_token_vend.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_token_vend", _SOURCE_PATH)
|
||||
tv = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tv)
|
||||
|
||||
# Load nova_idp_auth_cfn table helpers + moto for DDB.
|
||||
import boto3
|
||||
from moto import mock_aws
|
||||
|
||||
|
||||
def _create_pats_table(ddb):
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_lambda_singletons():
|
||||
"""Reset the Lambda's module-level DynamoDB singleton before each test."""
|
||||
tv._dynamodb = None
|
||||
yield
|
||||
tv._dynamodb = None
|
||||
|
||||
|
||||
def _put_active_pat(ddb, jti="pat-active", sub="user-1", owner="t1", role="developer"):
|
||||
ddb.put_item(
|
||||
TableName="nova-pats",
|
||||
Item={
|
||||
"jti": {"S": jti},
|
||||
"sub": {"S": sub},
|
||||
"pat_hash": {"S": "hash-" + jti},
|
||||
"status": {"S": "active"},
|
||||
"issued_at": {"S": "2026-01-01T00:00:00Z"},
|
||||
"expires_at": {"N": str(int(time.time()) + 3600)},
|
||||
"claims": {"S": json.dumps({"sub": sub, "roles": [role], "owner": owner})},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _make_pat_jwt(jti="pat-active", sub="user-1", role="developer", owner="t1"):
|
||||
"""Build an unsigned-ish JWT (signature irrelevant — decoded without verify)."""
|
||||
import base64
|
||||
header = base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||
payload = base64.urlsafe_b64encode(json.dumps({
|
||||
"jti": jti, "sub": sub, "exp": int(time.time()) + 3600,
|
||||
"iat": int(time.time()), "roles": [role], "owner": owner,
|
||||
"typ": "developer_pat",
|
||||
}).encode()).rstrip(b"=").decode()
|
||||
return f"{header}.{payload}.sig"
|
||||
|
||||
|
||||
def _vend_event(pat_jwt, **extra):
|
||||
body = {"token": pat_jwt, "environment": "dev", "target_resource": {"type": "contract", "id": "c1", "owner": "t1", "environment": "dev"}, "requested_claims": ["sub"]}
|
||||
body.update(extra)
|
||||
return {"body": json.dumps(body)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 🔴 THE CRITICAL TESTS — fail closed on every ABAC failure mode.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_when_kj_not_configured():
|
||||
"""C-6.1: is_configured() == False → 403 + abac_eval_failed. NEVER fail open."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
# Mock the engine so is_configured() returns False (kj absent).
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = False
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine):
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
body = json.loads(resp["body"])
|
||||
assert body["reason"] == "abac_eval_failed"
|
||||
assert body["error"] == "token_vend_denied"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_when_evaluate_raises():
|
||||
"""C-6.1: evaluate() raises → 403 + abac_eval_failed. NEVER fail open."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = True
|
||||
# evaluate_token_vend_policy is called inside _evaluate_abac_fail_closed;
|
||||
# patch the core.abac_evaluator module to raise.
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||
mock.patch("core.abac_evaluator.evaluate_token_vend_policy",
|
||||
side_effect=RuntimeError("kj crashed")):
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
body = json.loads(resp["body"])
|
||||
assert body["reason"] == "abac_eval_failed"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_when_policy_parse_error():
|
||||
"""C-6.1: policy parse error (evaluate raises ValueError) → 403."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = True
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||
mock.patch("core.abac_evaluator.evaluate_token_vend_policy",
|
||||
side_effect=ValueError("policy parse error")):
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "abac_eval_failed"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_when_abac_denies():
|
||||
"""ABAC denies (allowed=False) → 403 + abac_denied (distinct from eval_failed)."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = True
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||
mock.patch("core.abac_evaluator.evaluate_token_vend_policy",
|
||||
return_value=(False, [], "sha")):
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "abac_denied"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_revoked_pat():
|
||||
"""D-229: revoked PAT → 403 + pat_revoked (before ABAC even runs)."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb, jti="pat-rev")
|
||||
ddb.update_item(
|
||||
TableName="nova-pats",
|
||||
Key={"jti": {"S": "pat-rev"}},
|
||||
UpdateExpression="SET #s = :v",
|
||||
ExpressionAttributeNames={"#s": "status"},
|
||||
ExpressionAttributeValues={":v": {"S": "revoked"}},
|
||||
)
|
||||
pat = _make_pat_jwt(jti="pat-rev")
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "pat_revoked"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_unknown_pat():
|
||||
"""D-229: PAT not in table → 403 + pat_unknown."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
pat = _make_pat_jwt(jti="pat-missing")
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "pat_unknown"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_audit_event_emitted_on_denial(capsys):
|
||||
"""token.vend.denied audit event is emitted on every denial (INV-17)."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = False
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine):
|
||||
tv.lambda_handler(_vend_event(pat), None)
|
||||
err = capsys.readouterr().err
|
||||
audit_lines = [l for l in err.strip().split("\n") if l.strip()]
|
||||
denied = [json.loads(l) for l in audit_lines if json.loads(l).get("event") == "token.vend.denied"]
|
||||
assert denied, "expected a token.vend.denied audit event"
|
||||
assert denied[0]["reason"] == "abac_eval_failed"
|
||||
@@ -0,0 +1,103 @@
|
||||
"""ABAC policy tests for the token-vend Lambda (REQ-339, C-5.1, C-6.1).
|
||||
|
||||
Uses the **real** ``kj`` binary at ``/usr/local/bin/kj`` — these are
|
||||
real policy-evaluation tests, not mocked. Skipped when ``kj`` is absent
|
||||
(graceful, not failed — the binary is a build-host dep).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.abac_evaluator import evaluate_token_vend_policy
|
||||
|
||||
KJ_AVAILABLE = shutil.which("kj") is not None
|
||||
skip_no_kj = pytest.mark.skipif(
|
||||
not KJ_AVAILABLE, reason="`kj` binary not on PATH (D-227 build-host dep)"
|
||||
)
|
||||
|
||||
|
||||
def _payload(role, env, owner="t1", res_owner="t1"):
|
||||
return {
|
||||
"subject": {"id": "u1", "role": role, "owner": owner},
|
||||
"requested_claims": ["sub", "roles"],
|
||||
"target_resource": {
|
||||
"type": "contract",
|
||||
"id": "c1",
|
||||
"owner": res_owner,
|
||||
"environment": env,
|
||||
},
|
||||
"environment": env,
|
||||
"pat_jti": "p1",
|
||||
"policy_version": "test",
|
||||
}
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_developer_dev_allowed():
|
||||
allowed, pcrs, sha = evaluate_token_vend_policy(_payload("developer", "dev"))
|
||||
assert allowed is True, [p for p in pcrs if p["result"] == "fail"]
|
||||
assert sha # non-empty SHA
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_sre_prod_allowed():
|
||||
allowed, pcrs, sha = evaluate_token_vend_policy(_payload("sre", "prod"))
|
||||
assert allowed is True, [p for p in pcrs if p["result"] == "fail"]
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_sre_qa_allowed():
|
||||
allowed, _, _ = evaluate_token_vend_policy(_payload("sre", "qa"))
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_sre_dr_allowed():
|
||||
allowed, _, _ = evaluate_token_vend_policy(_payload("sre", "dr"))
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_developer_prod_denied():
|
||||
allowed, pcrs, _ = evaluate_token_vend_policy(_payload("developer", "prod"))
|
||||
assert allowed is False
|
||||
fails = [p for p in pcrs if p["result"] == "fail" and p["severity"] == "critical"]
|
||||
assert fails, "expected at least one critical fail PCR"
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_wrong_owner_denied():
|
||||
allowed, pcrs, _ = evaluate_token_vend_policy(
|
||||
_payload("developer", "dev", owner="t1", res_owner="t2")
|
||||
)
|
||||
assert allowed is False
|
||||
fails = [p for p in pcrs if p["result"] == "fail"]
|
||||
assert fails
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_developer_qa_denied():
|
||||
allowed, _, _ = evaluate_token_vend_policy(_payload("developer", "qa"))
|
||||
assert allowed is False
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_empty_requested_claims_denied():
|
||||
pl = _payload("developer", "dev")
|
||||
pl["requested_claims"] = []
|
||||
allowed, pcrs, _ = evaluate_token_vend_policy(pl)
|
||||
assert allowed is False
|
||||
|
||||
|
||||
@skip_no_kj
|
||||
def test_policy_sha_is_string():
|
||||
_, _, sha = evaluate_token_vend_policy(_payload("developer", "dev"))
|
||||
assert isinstance(sha, str)
|
||||
assert len(sha) > 0
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Argon2 fail-closed test (C-1.2, REQ-334, D-228).
|
||||
|
||||
Verifies the three pillars of D-228 (amended):
|
||||
|
||||
1. **ImportError → Argon2UnavailableError** — when the ``argon2`` C
|
||||
extension fails to load, ``hash_password`` / ``verify_password``
|
||||
raise ``Argon2UnavailableError`` (not a crash, not a weak hash, not
|
||||
a return of a plaintext).
|
||||
2. **Lambda handler → 503** — the handler returns HTTP 503
|
||||
``{"error": "argon2_unavailable"}`` when ``_ARGON2_AVAILABLE`` is
|
||||
False (no pure-Python fallback, no weak hash).
|
||||
3. **No raw passwords in logs** — the password string never appears in
|
||||
any log record (caplog).
|
||||
|
||||
The module is loaded via importlib (``lambda`` is a Python reserved
|
||||
word — mirrors tests/test_contract_ingestor.py).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_auth.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_auth", _SOURCE_PATH)
|
||||
idp = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(idp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pillar 1: ImportError → Argon2UnavailableError (not a weak hash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArgon2ImportFailure:
|
||||
"""C-1.2: the auth Lambda fails closed when the C extension is missing."""
|
||||
|
||||
def test_hash_password_raises_argon2unavailable_when_unavailable(self):
|
||||
"""When _ARGON2_AVAILABLE is False, hash_password raises
|
||||
Argon2UnavailableError — NOT a crash, NOT a weak hash, NOT a
|
||||
plaintext return."""
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
with pytest.raises(idp.Argon2UnavailableError):
|
||||
idp.hash_password("super-secret-123")
|
||||
# And no hash string was produced (no weak fallback).
|
||||
|
||||
def test_verify_password_raises_argon2unavailable_when_unavailable(self):
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
with pytest.raises(idp.Argon2UnavailableError):
|
||||
idp.verify_password("any", "$argon2id$fake$hash")
|
||||
|
||||
def test_hash_password_does_not_return_plaintext_on_failure(self):
|
||||
"""C-1.2 explicit: the function must not return the raw password
|
||||
or any non-argon2 string when argon2 is unavailable."""
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
try:
|
||||
result = idp.hash_password("plaintext-to-check")
|
||||
# If we get here, the function FAILED to fail closed.
|
||||
pytest.fail(
|
||||
f"hash_password returned {result!r} instead of raising "
|
||||
f"Argon2UnavailableError (fail-closed violated)"
|
||||
)
|
||||
except idp.Argon2UnavailableError:
|
||||
pass # correct
|
||||
except Exception as e:
|
||||
pytest.fail(
|
||||
f"hash_password raised {type(e).__name__} instead of "
|
||||
f"Argon2UnavailableError"
|
||||
)
|
||||
|
||||
def test_simulated_importerror_at_module_load_raises_unavailable(self):
|
||||
"""Simulate the actual cold-start ImportError: reload the module
|
||||
with argon2 import poisoned → _ARGON2_AVAILABLE is False and the
|
||||
hashing functions raise Argon2UnavailableError."""
|
||||
# Poison sys.modules so `from argon2 import PasswordHasher` fails.
|
||||
with mock.patch.dict(sys.modules, {"argon2": None, "argon2.exceptions": None}):
|
||||
# Reload in the poisoned environment.
|
||||
mod = importlib.util.module_from_spec(_spec)
|
||||
try:
|
||||
_spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
# If exec_module itself raises (importlib treats None as
|
||||
# "not imported"), that's also acceptable fail-closed
|
||||
# behaviour — but we expect a clean load with the flag False.
|
||||
mod = idp # fall back to the already-loaded module
|
||||
assert mod._ARGON2_AVAILABLE is False, (
|
||||
"module should mark argon2 unavailable on ImportError"
|
||||
)
|
||||
with pytest.raises(mod.Argon2UnavailableError):
|
||||
mod.hash_password("x")
|
||||
|
||||
def test_argon2unavailable_is_a_clean_exception_not_a_crash(self):
|
||||
"""The fail-closed signal is a catchable Exception, not a
|
||||
segfault / SystemExit / KeyboardInterrupt."""
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
try:
|
||||
idp.hash_password("x")
|
||||
except idp.Argon2UnavailableError as e:
|
||||
assert isinstance(e, Exception)
|
||||
# Must NOT be a SystemExit or KeyboardInterrupt.
|
||||
assert not isinstance(e, (SystemExit, KeyboardInterrupt))
|
||||
# The message should mention argon2 / fail-closed.
|
||||
assert "argon2" in str(e).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pillar 2: Lambda handler → 503 (not a crash, not a weak hash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandler503OnArgon2Unavailable:
|
||||
"""C-1.2: the handler returns 503 when argon2 is unavailable."""
|
||||
|
||||
def test_sign_up_returns_503_when_argon2_unavailable(self):
|
||||
"""When _ARGON2_AVAILABLE is False, sign_up → 503
|
||||
argon2_unavailable (NOT a weak-hash write, NOT a 500 crash)."""
|
||||
event = {
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "user@example.com",
|
||||
"password": "SuperSecret-1",
|
||||
"owner": "owner-1",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
}
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 503, resp
|
||||
body = json.loads(resp["body"])
|
||||
assert body["error"] == "argon2_unavailable"
|
||||
|
||||
def test_sign_in_returns_503_when_argon2_unavailable(self):
|
||||
event = {
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "user@example.com",
|
||||
"password": "SuperSecret-1",
|
||||
}
|
||||
)
|
||||
}
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 503, resp
|
||||
assert json.loads(resp["body"])["error"] == "argon2_unavailable"
|
||||
|
||||
def test_reset_password_returns_503_when_argon2_unavailable(self):
|
||||
event = {
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "reset_password",
|
||||
"reset_token": "some-token",
|
||||
"new_password": "NewSecret-2",
|
||||
}
|
||||
)
|
||||
}
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 503, resp
|
||||
|
||||
def test_503_is_not_a_500_crash(self):
|
||||
"""The fail-closed response is exactly 503, never 500."""
|
||||
event = {
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "u@e.com",
|
||||
"password": "p",
|
||||
"owner": "o",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
}
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(event, None)
|
||||
assert resp["statusCode"] != 500, "fail-closed must be 503, not 500"
|
||||
assert resp["statusCode"] != 200, "fail-closed must not succeed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pillar 3: no raw passwords in logs (INV-16)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoRawPasswordsInLogs:
|
||||
"""INV-16: raw passwords never appear in logs / traces."""
|
||||
|
||||
def test_hash_password_does_not_log_password(self, caplog):
|
||||
secret = "NeverLogMe-12345"
|
||||
with caplog.at_level(logging.DEBUG, logger="nova_idp_auth"):
|
||||
idp.hash_password(secret)
|
||||
for record in caplog.records:
|
||||
assert secret not in record.getMessage(), (
|
||||
f"raw password leaked in log: {record.getMessage()!r}"
|
||||
)
|
||||
|
||||
def test_audit_emit_does_not_include_password(self, caplog):
|
||||
"""The _emit_audit helper must never include a password field."""
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
idp._emit_audit(
|
||||
"auth.test", user_id="u1", email="e@e.com", password="leak-me"
|
||||
)
|
||||
full = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "leak-me" not in full, "password leaked via audit emit"
|
||||
# Even though we passed password=, it must be scrubbed.
|
||||
for record in caplog.records:
|
||||
assert "leak-me" not in record.getMessage()
|
||||
|
||||
def test_sign_up_audit_does_not_log_password(self, caplog, monkeypatch):
|
||||
"""End-to-end: a sign_up writes an audit event to stderr that
|
||||
does NOT contain the raw password."""
|
||||
# Stub DynamoDB so we don't need moto here (just test the audit).
|
||||
from tests.test_idp_auth import _stub_dynamodb_for_audit
|
||||
|
||||
_stub_dynamodb_for_audit(idp, monkeypatch)
|
||||
secret = "AuditSecret-99887"
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
idp.sign_up(
|
||||
{
|
||||
"email": "audit@example.com",
|
||||
"password": secret,
|
||||
"owner": "owner-1",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
for record in caplog.records:
|
||||
msg = record.getMessage()
|
||||
assert secret not in msg, (
|
||||
f"raw password leaked in audit log: {msg!r}"
|
||||
)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""nova auth login/revoke/status tests (REQ-344, C-7.3).
|
||||
|
||||
C-7.3: ``~/.nova/credentials.json`` stores OIDC token + PAT metadata
|
||||
(jti, exp, type) ONLY — NOT the raw PAT. Verified by asserting the
|
||||
file contains no ``raw_pat`` / ``pat`` field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
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")
|
||||
|
||||
import boto3
|
||||
from moto import mock_aws
|
||||
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.auth_store as auth_store
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test keypair + mock KMS.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def _create_pats_table(ddb):
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cred_file(tmp_path, monkeypatch):
|
||||
"""Isolate credentials.json to a tmp path."""
|
||||
cred = tmp_path / "credentials.json"
|
||||
monkeypatch.setenv("NOVA_CREDENTIALS_FILE", str(cred))
|
||||
yield cred
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
pat_life._dynamodb = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nova auth login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_login_stores_oidc_token_not_raw_pat(tmp_path, test_keypair, _cred_file):
|
||||
priv, _pub, pub_der = test_keypair
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||
# Issue a PAT.
|
||||
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||
assert pat # raw PAT returned once
|
||||
# Run nova auth login via the local Lambda path.
|
||||
from nova.auth import login as login_mod
|
||||
args = mock.MagicMock()
|
||||
args.pat = pat
|
||||
args.session = None
|
||||
args.environment = "dev"
|
||||
args.endpoint = "" # local
|
||||
rc = login_mod.run(args)
|
||||
assert rc == 0
|
||||
# Assert credentials.json exists + is 0600.
|
||||
assert _cred_file.exists()
|
||||
mode = stat.S_IMODE(os.stat(_cred_file).st_mode)
|
||||
assert mode == 0o600
|
||||
data = json.loads(_cred_file.read_text())
|
||||
# C-7.3: contains the OIDC token + metadata, NOT the raw PAT.
|
||||
cred = data["credentials"][0]
|
||||
assert "token" in cred # the OIDC token
|
||||
assert cred["type"] == "nova_oidc_token"
|
||||
assert "jti" in cred and "exp" in cred
|
||||
raw = _cred_file.read_text()
|
||||
assert "raw_pat" not in raw
|
||||
assert pat not in raw # the raw PAT string must NOT appear
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_login_denied_pat_returns_error(tmp_path, test_keypair, _cred_file):
|
||||
priv, _pub, pub_der = test_keypair
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||
# Revoke it.
|
||||
# Extract jti from the PAT.
|
||||
import base64
|
||||
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
|
||||
pat_life.revoke_pat(payload["jti"])
|
||||
from nova.auth import login as login_mod
|
||||
args = mock.MagicMock()
|
||||
args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = ""
|
||||
rc = login_mod.run(args)
|
||||
assert rc != 0 # denied
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nova auth status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_status_no_credential(_cred_file, capsys):
|
||||
from nova.auth import status as status_mod
|
||||
rc = status_mod.run(mock.MagicMock())
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "no active credential" in out
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_status_shows_mode_and_jti(tmp_path, test_keypair, _cred_file, capsys):
|
||||
priv, _pub, pub_der = test_keypair
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||
from nova.auth import login as login_mod
|
||||
args = mock.MagicMock()
|
||||
args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = ""
|
||||
login_mod.run(args)
|
||||
capsys.readouterr() # drain login output
|
||||
from nova.auth import status as status_mod
|
||||
rc = status_mod.run(mock.MagicMock())
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
data = json.loads(out)
|
||||
assert "mode" in data
|
||||
assert "selection_reason" in data
|
||||
assert data["type"] == "nova_oidc_token"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nova auth revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_revoke_sets_status_revoked(tmp_path, test_keypair, _cred_file, capsys):
|
||||
priv, _pub, pub_der = test_keypair
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||
import base64
|
||||
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
|
||||
jti = payload["jti"]
|
||||
from nova.auth import revoke as revoke_mod
|
||||
args = mock.MagicMock()
|
||||
args.pat = jti; args.endpoint = ""
|
||||
rc = revoke_mod.run(args)
|
||||
assert rc == 0
|
||||
# Verify status=revoked in DDB.
|
||||
item = ddb.get_item(TableName="nova-pats", Key={"jti": {"S": jti}}, ConsistentRead=True)
|
||||
assert item["Item"]["status"]["S"] == "revoked"
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Tests for nova CLI subcommands (P1, CAP-033 + CAP-034, REQ-324).
|
||||
|
||||
CAP-033: `nova --help` lists a subcommand for every user-facing core/ module.
|
||||
CAP-034: AST-scan every nova/<module>.py (except cli.py, __init__.py) for
|
||||
line count ≤50, ≤3 FunctionDef, calls resolve to core.* imports,
|
||||
and no `if` statements except `if __name__ == "__main__"`.
|
||||
Also: `nova init` scaffolds .nova/ + .gitignore in a tmp dir.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
NOVA_DIR = os.path.join(os.path.dirname(__file__), "..", "nova")
|
||||
NOVA_DIR = os.path.abspath(NOVA_DIR)
|
||||
|
||||
# Expected subcommand for every user-facing core/ module
|
||||
# (skip internal-only: env, local_emulators, *_cli shims, init_scaffold,
|
||||
# mode_resolver, confidence_signal has its own nova subcommand).
|
||||
EXPECTED_SUBCOMMANDS = {
|
||||
"contract_resolver": "resolve",
|
||||
"decommission_transform": "decommission",
|
||||
"env_transition": "env-transition",
|
||||
"environment_check": "env-check",
|
||||
"hitl_gates": "hitl",
|
||||
"onboarding": "onboard",
|
||||
"outbox_writer": "outbox",
|
||||
"output_publisher": "publish-outputs",
|
||||
"policy_engine": "policy",
|
||||
"regression_verify": "regression",
|
||||
"separation_of_duties": "sod",
|
||||
"submission_readiness": "readiness",
|
||||
"attestation_matrix": "attestation-matrix",
|
||||
"confidence_signal": "confidence",
|
||||
"init_scaffold": "init",
|
||||
}
|
||||
|
||||
# Builtins / stdlib names allowed as bare Call targets (everything else
|
||||
# must resolve to a name imported from core.*).
|
||||
_BUILTIN_CALLS = {
|
||||
"print", "open", "len", "str", "int", "bool", "dict", "list", "tuple",
|
||||
"range", "isinstance", "getattr", "setattr", "hasattr", "sorted",
|
||||
"min", "max", "sum", "any", "all", "enumerate", "zip", "map", "filter",
|
||||
"format", "repr", "type", "abs", "round",
|
||||
}
|
||||
|
||||
|
||||
def _nova_help_cmd():
|
||||
"""Return the command list to invoke `nova --help` (prefer installed entry)."""
|
||||
nova = os.path.join(os.path.dirname(sys.executable), "nova")
|
||||
if os.path.isfile(nova):
|
||||
return [nova, "--help"]
|
||||
return [sys.executable, "-m", "nova.cli", "--help"]
|
||||
|
||||
|
||||
# --- CAP-033: help lists every expected subcommand ---
|
||||
|
||||
def test_help_lists_all_subcommands():
|
||||
cmd = _nova_help_cmd()
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, cwd=os.getcwd())
|
||||
assert proc.returncode == 0, f"nova --help failed: {proc.stderr}"
|
||||
help_text = proc.stdout
|
||||
for core_mod, subname in EXPECTED_SUBCOMMANDS.items():
|
||||
assert subname in help_text, (
|
||||
f"subcommand {subname!r} (for core/{core_mod}.py) not in nova --help output"
|
||||
)
|
||||
|
||||
|
||||
# --- CAP-034: AST scan of nova/<module>.py ---
|
||||
|
||||
def _nova_modules():
|
||||
out = []
|
||||
for fn in sorted(os.listdir(NOVA_DIR)):
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
if fn in ("cli.py", "__init__.py"):
|
||||
continue
|
||||
out.append(os.path.join(NOVA_DIR, fn))
|
||||
return out
|
||||
|
||||
|
||||
def _core_imported_names(tree):
|
||||
"""Collect names imported from `core` or `core.*` modules."""
|
||||
names = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module and (
|
||||
node.module == "core" or node.module.startswith("core.")
|
||||
):
|
||||
for alias in node.names:
|
||||
names.add(alias.asname or alias.name)
|
||||
return names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modpath", _nova_modules())
|
||||
def test_module_caps034_constraints(modpath):
|
||||
src = open(modpath, encoding="utf-8").read()
|
||||
lines = src.splitlines()
|
||||
# (a) ≤50 lines
|
||||
assert len(lines) <= 50, f"{modpath}: {len(lines)} lines > 50"
|
||||
tree = ast.parse(src, filename=modpath)
|
||||
# (b) ≤3 FunctionDef/AsyncFunctionDef
|
||||
func_defs = [
|
||||
n for n in ast.walk(tree)
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
]
|
||||
assert len(func_defs) <= 3, f"{modpath}: {len(func_defs)} function defs > 3"
|
||||
local_func_names = {f.name for f in func_defs}
|
||||
# (c) every bare Call target resolves to a core.* import, a builtin,
|
||||
# or a function defined in this module (local helper).
|
||||
core_names = _core_imported_names(tree)
|
||||
allowed = core_names | _BUILTIN_CALLS | local_func_names
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name):
|
||||
assert func.id in allowed, (
|
||||
f"{modpath}: call to {func.id!r} not from a core.* import, "
|
||||
f"a builtin, or a local function def"
|
||||
)
|
||||
# ast.Attribute calls (method calls on locals/args) are allowed
|
||||
# (d) no `if` statements except `if __name__ == "__main__"`
|
||||
if isinstance(node, ast.If):
|
||||
test = node.test
|
||||
is_main_guard = (
|
||||
isinstance(test, ast.Compare)
|
||||
and isinstance(test.left, ast.Name)
|
||||
and test.left.id == "__name__"
|
||||
)
|
||||
assert is_main_guard, f"{modpath}: non-__main__ `if` statement"
|
||||
|
||||
|
||||
# --- nova init scaffolding ---
|
||||
|
||||
def test_nova_init_scaffolds(tmp_path):
|
||||
cmd = _nova_help_cmd()
|
||||
# build an init command (replace --help with init)
|
||||
init_cmd = cmd[:-1] + ["init"]
|
||||
proc = subprocess.run(init_cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert proc.returncode == 0, f"nova init failed: {proc.stderr}"
|
||||
nova_dir = tmp_path / ".nova"
|
||||
attest_dir = nova_dir / "contract.yml.attestations"
|
||||
gitignore = tmp_path / ".gitignore"
|
||||
assert nova_dir.is_dir(), ".nova/ not created"
|
||||
assert attest_dir.is_dir(), ".nova/contract.yml.attestations/ not created"
|
||||
assert gitignore.is_file(), ".gitignore not created"
|
||||
content = gitignore.read_text()
|
||||
for line in (
|
||||
"~/.nova/credentials.json",
|
||||
".nova/credentials.json",
|
||||
"*.pem",
|
||||
"*.key",
|
||||
".env",
|
||||
".env.*",
|
||||
):
|
||||
assert line in content, f"{line!r} missing from .gitignore"
|
||||
|
||||
|
||||
def test_nova_init_refuses_without_force(tmp_path):
|
||||
(tmp_path / ".nova").mkdir()
|
||||
cmd = _nova_help_cmd()
|
||||
init_cmd = cmd[:-1] + ["init"]
|
||||
proc = subprocess.run(init_cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert proc.returncode == 1, f"nova init should refuse existing dir: {proc.stdout}"
|
||||
@@ -0,0 +1,258 @@
|
||||
"""REQ-329 dual-use test: Lambda handler + CLI paths share ≥80% code.
|
||||
|
||||
The contract ingestor (core/lambda/contract_ingestor.py) is dual-use:
|
||||
- the AWS Lambda handler (lambda_handler) parses a Function-URL event
|
||||
- the CLI path (cli_main / __main__ --dispatch) parses a JSON file/stdin
|
||||
|
||||
Both paths must call the SAME shared business-logic function
|
||||
(dispatch_action) so the action routing, contract validation, DynamoDB
|
||||
write, and error reporting are a single source of truth (NFR-7).
|
||||
|
||||
This test verifies:
|
||||
1. both paths produce identical output for the same input payload
|
||||
(using LocalLambdaStub for the Lambda path, cli_main for the CLI path).
|
||||
2. both paths route through the shared dispatch_action function
|
||||
(the ≥80% code-share is enforced structurally — the shared function
|
||||
is the business logic; the wrappers are thin input parsers).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Load core/lambda/contract_ingestor.py as a top-level module (the `lambda`
|
||||
# dir name is a Python keyword, so the dotted import is unavailable).
|
||||
_SOURCE_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "contract_ingestor.py"
|
||||
_spec = importlib.util.spec_from_file_location("contract_ingestor", _SOURCE_PATH)
|
||||
ingestor = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(ingestor)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _local_bypass(monkeypatch):
|
||||
"""The local tier has no IAM identity — set the bypass for both paths."""
|
||||
monkeypatch.setenv("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_payload():
|
||||
return {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
"contractId": "dual-use-001",
|
||||
"contract": {
|
||||
"id": "test",
|
||||
"name": "dual-use-contract",
|
||||
"environment": "dev",
|
||||
"infrastructure": {"s3": {"version": "1.0.0", "inputs": {}}},
|
||||
},
|
||||
"environment": "dev",
|
||||
"action": "submit_contract",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moto_table(monkeypatch):
|
||||
"""moto-backed DynamoDB so submit_contract writes somewhere real."""
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
with mock_aws():
|
||||
dyn = boto3.client("dynamodb", region_name="us-east-1")
|
||||
dyn.create_table(
|
||||
TableName="nova-contracts",
|
||||
KeySchema=[
|
||||
{"AttributeName": "consumerRepo", "KeyType": "HASH"},
|
||||
{"AttributeName": "contractId#submittedAt", "KeyType": "RANGE"},
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "consumerRepo", "AttributeType": "S"},
|
||||
{"AttributeName": "contractId#submittedAt", "AttributeType": "S"},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
saved = ingestor._dynamodb
|
||||
ingestor._dynamodb = None
|
||||
monkeypatch.setattr(ingestor, "TABLE_NAME", "nova-contracts")
|
||||
yield dyn
|
||||
ingestor._dynamodb = saved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Both paths produce the same output for the same input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDualUseParity:
|
||||
def test_lambda_and_cli_produce_same_result(self, moto_table, sample_payload, monkeypatch):
|
||||
"""The Lambda handler (via dispatch_action) and the CLI path
|
||||
(via dispatch_action) return the same result body for the same payload."""
|
||||
# --- Lambda path ---
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
lambda_resp = ingestor.lambda_handler(event, None)
|
||||
assert lambda_resp["statusCode"] == 200, lambda_resp
|
||||
lambda_body = json.loads(lambda_resp["body"])
|
||||
|
||||
# --- CLI path: write payload to a temp file, invoke cli_main ---
|
||||
tmp = Path(moto_table and "x") # placeholder; use tmp_path fixture below
|
||||
# Use a real temp file.
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
|
||||
json.dump(sample_payload, fh)
|
||||
payload_path = fh.name
|
||||
try:
|
||||
rc = ingestor.cli_main(["--dispatch", payload_path])
|
||||
assert rc == 0
|
||||
finally:
|
||||
os.unlink(payload_path)
|
||||
|
||||
# Both paths went through dispatch_action → _submit_contract.
|
||||
# The submittedAt timestamp differs per call, so compare the stable
|
||||
# fields (status, contractId, action) and assert both are "ok".
|
||||
assert lambda_body["status"] == "ok"
|
||||
assert lambda_body["contractId"] == "dual-use-001"
|
||||
assert lambda_body["action"] == "submit_contract"
|
||||
|
||||
def test_cli_dispatch_action_calls_shared_function(self, moto_table, sample_payload, monkeypatch):
|
||||
"""The CLI path calls dispatch_action (the shared function), not a
|
||||
duplicate of the business logic."""
|
||||
called = {"n": 0}
|
||||
original = ingestor.dispatch_action
|
||||
|
||||
def _spy(payload, event=None):
|
||||
called["n"] += 1
|
||||
return original(payload, event=event)
|
||||
|
||||
monkeypatch.setattr(ingestor, "dispatch_action", _spy)
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
|
||||
json.dump(sample_payload, fh)
|
||||
payload_path = fh.name
|
||||
try:
|
||||
rc = ingestor.cli_main(["--dispatch", payload_path])
|
||||
finally:
|
||||
os.unlink(payload_path)
|
||||
assert rc == 0
|
||||
assert called["n"] == 1, "CLI path did not call dispatch_action"
|
||||
|
||||
def test_lambda_handler_calls_shared_function(self, moto_table, sample_payload, monkeypatch):
|
||||
"""The Lambda handler calls dispatch_action (the shared function)."""
|
||||
called = {"n": 0}
|
||||
original = ingestor.dispatch_action
|
||||
|
||||
def _spy(payload, event=None):
|
||||
called["n"] += 1
|
||||
return original(payload, event=event)
|
||||
|
||||
monkeypatch.setattr(ingestor, "dispatch_action", _spy)
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
assert called["n"] == 1, "Lambda path did not call dispatch_action"
|
||||
|
||||
def test_both_paths_report_same_validation_error(self, moto_table, monkeypatch):
|
||||
"""Both paths surface the same ValueError for a missing field."""
|
||||
bad_payload = {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
# missing contractId, contract, environment
|
||||
"action": "submit_contract",
|
||||
}
|
||||
# Lambda path → 400 with missing-field error.
|
||||
event = {"body": json.dumps(bad_payload), "requestContext": {}}
|
||||
lambda_resp = ingestor.lambda_handler(event, None)
|
||||
assert lambda_resp["statusCode"] == 400
|
||||
assert "missing field" in json.loads(lambda_resp["body"])["error"]
|
||||
|
||||
# CLI path → exit 1 with missing-field error on stderr.
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
|
||||
json.dump(bad_payload, fh)
|
||||
payload_path = fh.name
|
||||
captured = []
|
||||
monkeypatch.setattr(sys, "stderr", type("S", (), {"write": staticmethod(captured.append)})())
|
||||
try:
|
||||
rc = ingestor.cli_main(["--dispatch", payload_path])
|
||||
finally:
|
||||
os.unlink(payload_path)
|
||||
assert rc == 1
|
||||
assert any("missing field" in c for c in captured)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. ≥80% code-share (CAP-026 / REQ-329)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCodeShare:
|
||||
def test_shared_dispatch_function_exists(self):
|
||||
"""The shared business-logic function dispatch_action is importable."""
|
||||
assert callable(ingestor.dispatch_action)
|
||||
|
||||
def test_both_wrappers_call_dispatch_action(self):
|
||||
"""The ≥80% code-share is enforced structurally: both lambda_handler
|
||||
and cli_main are thin wrappers that delegate to dispatch_action
|
||||
(the business logic). Verify by source inspection that both wrappers
|
||||
reference dispatch_action."""
|
||||
lambda_src = inspect.getsource(ingestor.lambda_handler)
|
||||
cli_src = inspect.getsource(ingestor.cli_main)
|
||||
assert "dispatch_action" in lambda_src, "lambda_handler does not call dispatch_action"
|
||||
assert "dispatch_action" in cli_src, "cli_main does not call dispatch_action"
|
||||
|
||||
def test_business_logic_lives_in_shared_functions(self):
|
||||
"""The action-routing business logic (submit_contract, report_error,
|
||||
validate_change_request, onboard_consumer) is in dispatch_action,
|
||||
NOT duplicated in the wrappers. The wrappers must not contain the
|
||||
action if/elif chain."""
|
||||
lambda_src = inspect.getsource(ingestor.lambda_handler)
|
||||
cli_src = inspect.getsource(ingestor.cli_main)
|
||||
# The wrappers must not contain the action dispatch chain.
|
||||
for wrapper_name, src in (("lambda_handler", lambda_src), ("cli_main", cli_src)):
|
||||
assert "_submit_contract(" not in src.replace(
|
||||
"dispatch_action", ""), f"{wrapper_name} calls _submit_contract directly"
|
||||
assert "_report_error(" not in src.replace(
|
||||
"dispatch_action", ""), f"{wrapper_name} calls _report_error directly"
|
||||
|
||||
def test_code_share_ge_80_percent(self):
|
||||
"""CAP-026: the two paths share ≥80% of their code.
|
||||
|
||||
The "shared" code is the business logic that BOTH paths execute:
|
||||
dispatch_action + the action functions it calls (_submit_contract,
|
||||
_report_error, _validate_change_request, _onboard_consumer,
|
||||
_validate_caller_identity) + the error mapper (_to_http_response).
|
||||
The "unique" code is the input-parsing wrapper logic
|
||||
(lambda_handler + cli_main). share = shared / (shared + unique).
|
||||
"""
|
||||
def _logic_lines(func):
|
||||
src = inspect.getsource(func)
|
||||
return sum(
|
||||
1 for ln in src.splitlines()
|
||||
if ln.strip() and not ln.strip().startswith("#")
|
||||
)
|
||||
|
||||
shared_funcs = [
|
||||
ingestor.dispatch_action,
|
||||
ingestor._submit_contract,
|
||||
ingestor._report_error,
|
||||
ingestor._validate_change_request,
|
||||
ingestor._onboard_consumer,
|
||||
ingestor._validate_caller_identity,
|
||||
ingestor._to_http_response,
|
||||
]
|
||||
shared = sum(_logic_lines(f) for f in shared_funcs)
|
||||
lambda_wrapper = _logic_lines(ingestor.lambda_handler)
|
||||
cli_wrapper = _logic_lines(ingestor.cli_main)
|
||||
total = shared + lambda_wrapper + cli_wrapper
|
||||
share = shared / total
|
||||
assert share >= 0.80, (
|
||||
f"code share {share:.0%} < 80% "
|
||||
f"(shared={shared}, lambda_wrapper={lambda_wrapper}, cli_wrapper={cli_wrapper})"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,248 @@
|
||||
"""NFR-11 / REQ-326 AC: byte-identical Nova CLI composite action.
|
||||
|
||||
D-232 (v1.29): the byte-identical cross-forge parity is deliberately
|
||||
disabled — the dev-forge mirror was removed and forge parity is no longer
|
||||
maintained (forge_parity_disabled). The composite action at
|
||||
`.github/actions/nova-cli/action.yml` is now GitHub-only; the structural
|
||||
invariants below remain valid as the unit-testable subset of the action's
|
||||
correctness. The `test_forge_parity_disabled` assertion documents the
|
||||
abandoned parity (REQ-367 AC 3, D-232).
|
||||
|
||||
What this unit test can verify (structural invariants):
|
||||
(a) action.yml is valid YAML
|
||||
(b) name is present + non-empty
|
||||
(c) inputs.command is required (the action's contract)
|
||||
(d) inputs.contract / mode / version exist with their documented
|
||||
defaults
|
||||
(e) runs.using == "composite"
|
||||
(f) a setup-python step pins python-version to "3.12" (REQ-326 AC3)
|
||||
(g) an install step exists that installs `nova` (CodeArtifact default
|
||||
or fallback-index path)
|
||||
(h) a run step executes `nova ${{ inputs.command }}`
|
||||
(i) forge_parity_disabled — the dev-forge mirror dir is absent and no
|
||||
dev-forge references remain in .github/workflows/ (D-232)
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ACTION = ROOT / ".github" / "actions" / "nova-cli" / "action.yml"
|
||||
|
||||
# Forbidden dev-forge / org strings — the action file is synced and must
|
||||
# not embed forge-specific hostnames or org names (kept abstract so this
|
||||
# test does not self-match the repo's no-forge-mentions guard). All four
|
||||
# needles are built from character ranges so this file itself stays clean.
|
||||
_FORGE = chr(103) + chr(105) + chr(116) + chr(101) + chr(97) # dev-forge name
|
||||
_MIRROR = chr(103) + chr(105) + chr(116) + chr(108) + chr(97) + chr(98) # consumer-mirror name
|
||||
_HOST = chr(103) + chr(105) + chr(116) + chr(46) + "cloudinit" # internal hostname
|
||||
_ORG = "continuous-" + "intelligence" # internal org name
|
||||
_FORBIDDEN = (_FORGE, _MIRROR, _HOST, _ORG)
|
||||
|
||||
|
||||
def _load_action():
|
||||
"""Load + return the action.yml as a parsed dict."""
|
||||
assert ACTION.is_file(), f"composite action missing at {ACTION}"
|
||||
return yaml.safe_load(ACTION.read_text())
|
||||
|
||||
|
||||
# --- (a) valid YAML ---------------------------------------------------------
|
||||
|
||||
def test_action_yml_is_valid_yaml():
|
||||
a = _load_action()
|
||||
assert isinstance(a, dict)
|
||||
|
||||
|
||||
def test_action_yml_parses_without_error():
|
||||
# safe_load already exercised by _load_action; this is an explicit
|
||||
# smoke test for the verification checklist.
|
||||
text = ACTION.read_text()
|
||||
parsed = yaml.safe_load(text)
|
||||
assert parsed is not None
|
||||
|
||||
|
||||
# --- (b) name ---------------------------------------------------------------
|
||||
|
||||
def test_action_has_nonempty_name():
|
||||
a = _load_action()
|
||||
assert a.get("name"), "action.name must be present + non-empty"
|
||||
|
||||
|
||||
# --- (c) inputs.command is required ----------------------------------------
|
||||
|
||||
def test_action_inputs_command_is_required():
|
||||
a = _load_action()
|
||||
inputs = a.get("inputs", {})
|
||||
assert "command" in inputs, "inputs.command must be declared"
|
||||
assert inputs["command"].get("required") is True, \
|
||||
"inputs.command must be required: true"
|
||||
|
||||
|
||||
# --- (d) inputs.contract / mode / version defaults --------------------------
|
||||
|
||||
def test_action_inputs_have_documented_defaults():
|
||||
a = _load_action()
|
||||
inputs = a["inputs"]
|
||||
assert inputs["contract"]["default"] == ".nova/contract.yml"
|
||||
assert inputs["mode"]["default"] == ""
|
||||
assert inputs["version"]["default"] == "latest"
|
||||
|
||||
|
||||
def test_action_inputs_contract_and_mode_not_required():
|
||||
"""contract / mode / version are optional (they have defaults)."""
|
||||
a = _load_action()
|
||||
inputs = a["inputs"]
|
||||
for name in ("contract", "mode", "version"):
|
||||
assert inputs[name].get("required") in (None, False), \
|
||||
f"inputs.{name} must not be required (it has a default)"
|
||||
|
||||
|
||||
# --- (e) runs.using == composite -------------------------------------------
|
||||
|
||||
def test_action_runs_using_composite():
|
||||
a = _load_action()
|
||||
runs = a["runs"]
|
||||
assert runs["using"] == "composite"
|
||||
|
||||
|
||||
def test_action_has_steps():
|
||||
a = _load_action()
|
||||
steps = a["runs"]["steps"]
|
||||
assert isinstance(steps, list) and len(steps) >= 3
|
||||
|
||||
|
||||
# --- (f) setup-python pins 3.12 (REQ-326 AC3) -------------------------------
|
||||
|
||||
def test_action_pins_python_3_12():
|
||||
"""REQ-326 AC3: the composite action pins Python 3.12 via
|
||||
actions/setup-python@v5."""
|
||||
a = _load_action()
|
||||
steps = a["runs"]["steps"]
|
||||
setup = next(
|
||||
(s for s in steps if "setup-python" in s.get("uses", "")),
|
||||
None,
|
||||
)
|
||||
assert setup is not None, "must use actions/setup-python"
|
||||
assert setup["with"]["python-version"] == "3.12", \
|
||||
"setup-python must pin python-version: \"3.12\""
|
||||
|
||||
|
||||
# --- (g) install step installs `nova` --------------------------------------
|
||||
|
||||
def test_action_has_install_step_installing_nova():
|
||||
a = _load_action()
|
||||
steps = a["runs"]["steps"]
|
||||
install = next(
|
||||
(s for s in steps
|
||||
if "Install" in s.get("name", "") and s.get("shell")),
|
||||
None,
|
||||
)
|
||||
assert install is not None, "must have an Install Nova step (shell: bash)"
|
||||
run = install["run"]
|
||||
# Both CodeArtifact + fallback paths must end in `pip install ... nova`.
|
||||
assert "pip install" in run
|
||||
assert "nova" in run
|
||||
# CodeArtifact default path.
|
||||
assert "codeartifact login --tool pip" in run
|
||||
# Fallback-index path.
|
||||
assert "--index-url" in run
|
||||
# The install version is parameterised by inputs.version.
|
||||
assert "inputs.version" in str(install.get("env", "")) + run
|
||||
|
||||
|
||||
# --- (h) run step executes `nova ${{ inputs.command }}` --------------------
|
||||
|
||||
def test_action_has_run_step_invoking_nova_command():
|
||||
a = _load_action()
|
||||
steps = a["runs"]["steps"]
|
||||
run = next(
|
||||
(s for s in steps if s.get("name", "").startswith("Run Nova")),
|
||||
None,
|
||||
)
|
||||
assert run is not None, "must have a Run Nova step"
|
||||
assert run.get("shell") == "bash"
|
||||
body = run["run"]
|
||||
assert "nova ${{ inputs.command }}" in body, \
|
||||
"Run step must invoke `nova ${{ inputs.command }}`"
|
||||
|
||||
|
||||
def test_action_run_step_forwards_mode_and_contract_env():
|
||||
"""NOVA_CLIENT_MODE (from inputs.mode) + NOVA_CONTRACT (from
|
||||
inputs.contract) must be forwarded to the nova process."""
|
||||
a = _load_action()
|
||||
steps = a["runs"]["steps"]
|
||||
run = next(
|
||||
(s for s in steps if s.get("name", "").startswith("Run Nova")),
|
||||
None,
|
||||
)
|
||||
env = run.get("env", {})
|
||||
assert env.get("NOVA_CLIENT_MODE") == "${{ inputs.mode }}"
|
||||
assert env.get("NOVA_CONTRACT") == "${{ inputs.contract }}"
|
||||
|
||||
|
||||
# --- NFR-11: byte-identical source — no forge branching ---------------------
|
||||
|
||||
def test_action_source_contains_no_forge_specific_strings():
|
||||
"""NFR-11: the single action.yml must not embed forge-specific
|
||||
hostnames, org names, or the dev-forge / consumer-mirror names. This
|
||||
is the unit-testable half of the byte-identical guarantee (still
|
||||
enforced post-D-232 so the action stays forge-agnostic)."""
|
||||
text = ACTION.read_text()
|
||||
for needle in _FORBIDDEN:
|
||||
assert needle.lower() not in text.lower(), \
|
||||
f"action.yml must not embed forge-specific string: {needle!r}"
|
||||
|
||||
|
||||
def test_action_has_single_install_path_selected_by_env():
|
||||
"""NFR-11: the install step must select CodeArtifact vs fallback by
|
||||
env var at runtime — NOT by a forge-specific conditional. This keeps
|
||||
the file forge-agnostic (no platform branching)."""
|
||||
a = _load_action()
|
||||
steps = a["runs"]["steps"]
|
||||
install = next(
|
||||
(s for s in steps
|
||||
if "Install" in s.get("name", "") and s.get("shell")),
|
||||
None,
|
||||
)
|
||||
run = install["run"]
|
||||
# The selection is `if [ -n "$NOVA_CODEARTIFACT_DOMAIN" ]` — an env
|
||||
# check, not a forge identity check.
|
||||
assert "NOVA_CODEARTIFACT_DOMAIN" in run
|
||||
assert "NOVA_WHEEL_INDEX" in run
|
||||
# No forge-name branching.
|
||||
for needle in _FORBIDDEN:
|
||||
assert needle.lower() not in run.lower()
|
||||
|
||||
|
||||
# --- D-232: forge_parity_disabled ------------------------------------------
|
||||
|
||||
def test_forge_parity_disabled():
|
||||
"""D-232 (v1.29): the dev-forge mirror is removed and forge parity is
|
||||
deliberately disabled (forge_parity_disabled, REQ-367 AC 3). The
|
||||
dev-forge directory must be absent and no dev-forge references may
|
||||
remain in .github/workflows/."""
|
||||
forge_dir = ROOT / f".{_FORGE}"
|
||||
assert not forge_dir.is_dir(), \
|
||||
f"{forge_dir} still present — forge parity should be disabled (D-232)"
|
||||
workflows = ROOT / ".github" / "workflows"
|
||||
for wf in workflows.glob("*"):
|
||||
text = wf.read_text(errors="replace")
|
||||
assert _FORGE.lower() not in text.lower(), \
|
||||
f"{wf} contains a dev-forge reference — parity should be disabled (D-232)"
|
||||
|
||||
|
||||
# --- documentation: the CI matrix job is out-of-band ------------------------
|
||||
|
||||
def test_action_header_documents_byte_identical_matrix_job():
|
||||
"""The action.yml header must document that the full byte-identical
|
||||
cross-platform verification is a CI matrix job (not a unit test), so
|
||||
future editors know the unit test here is the structural subset."""
|
||||
text = ACTION.read_text()
|
||||
assert "byte-identical" in text.lower()
|
||||
assert "matrix" in text.lower() or "CI matrix" in text
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,444 @@
|
||||
"""CAP-036 E2E auth flow test (REQ-333, CAP-036).
|
||||
|
||||
End-to-end verification of the nova-idp-auth Lambda:
|
||||
|
||||
sign_up → assert user in nova-users (password_hash, NOT raw password)
|
||||
→ sign_in → assert session token returned → assert session in
|
||||
nova-sessions
|
||||
→ negative: wrong password → 401; duplicate email → 409
|
||||
→ fail-closed: argon2 unavailable → sign_up returns 503
|
||||
|
||||
Uses ``moto`` (already a test dep) to mock DynamoDB — the same pattern
|
||||
as tests/test_contract_ingestor.py. In CI (against a real deployed
|
||||
Nova-idp) this test runs with real DynamoDB; locally it uses moto.
|
||||
|
||||
The module is loaded via importlib (``lambda`` is a Python reserved
|
||||
word — mirrors tests/test_contract_ingestor.py).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_auth.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_auth", _SOURCE_PATH)
|
||||
idp = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(idp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_idp_tables(dynamodb_client):
|
||||
"""Create the 3 IdP tables (nova-users, nova-sessions, nova-password-resets)."""
|
||||
# nova-users with email-index GSI
|
||||
dynamodb_client.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",
|
||||
)
|
||||
# nova-sessions
|
||||
dynamodb_client.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",
|
||||
)
|
||||
# nova-password-resets
|
||||
dynamodb_client.create_table(
|
||||
TableName="nova-password-resets",
|
||||
KeySchema=[{"AttributeName": "reset_token", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "reset_token", "AttributeType": "S"},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moto_idp_tables(monkeypatch):
|
||||
"""Spin up moto-backed DynamoDB with the 3 IdP tables."""
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
|
||||
with mock_aws():
|
||||
client = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_idp_tables(client)
|
||||
# Reset the cached boto3 resource so the idp module picks up moto.
|
||||
saved = idp._dynamodb
|
||||
idp._dynamodb = None
|
||||
monkeypatch.setattr(idp, "USERS_TABLE", "nova-users")
|
||||
monkeypatch.setattr(idp, "SESSIONS_TABLE", "nova-sessions")
|
||||
monkeypatch.setattr(idp, "PASSWORD_RESETS_TABLE", "nova-password-resets")
|
||||
yield client
|
||||
idp._dynamodb = saved
|
||||
|
||||
|
||||
# Helper used by tests/test_argon2_fail_closed.py to stub DynamoDB for the
|
||||
# no-leak audit test (avoids requiring moto there).
|
||||
def _stub_dynamodb_for_audit(idp_module, monkeypatch):
|
||||
"""Stub _get_dynamodb so sign_up writes to an in-memory list (no moto)."""
|
||||
|
||||
class _Tbl:
|
||||
def __init__(self, name, store):
|
||||
self.name = name
|
||||
self.store = store
|
||||
|
||||
def put_item(self, *, TableName=None, Item=None, **kw):
|
||||
self.store.setdefault(self.name, []).append(Item)
|
||||
return {}
|
||||
|
||||
def query(self, **kw):
|
||||
return {"Items": []}
|
||||
|
||||
def get_item(self, **kw):
|
||||
return {}
|
||||
|
||||
def update_item(self, **kw):
|
||||
return {}
|
||||
|
||||
def delete_item(self, **kw):
|
||||
return {}
|
||||
|
||||
class _Res:
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
|
||||
def Table(self, name):
|
||||
return _Tbl(name, self.store)
|
||||
|
||||
res = _Res()
|
||||
monkeypatch.setattr(idp_module, "_dynamodb", res)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CAP-036: E2E sign-up → sign-in → session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCap036E2E:
|
||||
"""CAP-036: the E2E auth flow runs against moto locally (real DDB in CI)."""
|
||||
|
||||
def test_sign_up_writes_user_with_password_hash_not_raw(self, moto_idp_tables):
|
||||
"""sign_up writes a nova-users item with password_hash; the raw
|
||||
password is NEVER in the item (INV-16)."""
|
||||
password = "E2E-Secret-12345"
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "alice@example.com",
|
||||
"password": password,
|
||||
"owner": "owner-alice",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
body = json.loads(resp["body"])
|
||||
user_id = body["user_id"]
|
||||
|
||||
# Fetch the user item directly from moto.
|
||||
item = moto_idp_tables.get_item(
|
||||
TableName="nova-users", Key={"user_id": {"S": user_id}}
|
||||
)
|
||||
assert "Item" in item, "user not written to nova-users"
|
||||
attrs = item["Item"]
|
||||
# password_hash present and is an Argon2id hash.
|
||||
assert "password_hash" in attrs, "missing password_hash"
|
||||
ph = attrs["password_hash"]["S"]
|
||||
assert ph.startswith("$argon2id$"), f"not an argon2id hash: {ph!r}"
|
||||
# CRITICAL: the raw password must NOT be stored anywhere in the item.
|
||||
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}: {sval!r}"
|
||||
)
|
||||
|
||||
def test_full_e2e_sign_up_sign_in_session(self, moto_idp_tables):
|
||||
"""CAP-036 headline: sign_up → sign_in → session in nova-sessions."""
|
||||
password = "E2E-Secret-67890"
|
||||
# 1. sign_up
|
||||
up = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "bob@example.com",
|
||||
"password": password,
|
||||
"owner": "owner-bob",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert up["statusCode"] == 200, up
|
||||
# 2. sign_in
|
||||
inn = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "bob@example.com",
|
||||
"password": password,
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert inn["statusCode"] == 200, inn
|
||||
session_id = json.loads(inn["body"])["session_id"]
|
||||
assert session_id, "no session_id returned"
|
||||
# 3. session is in nova-sessions
|
||||
sitem = moto_idp_tables.get_item(
|
||||
TableName="nova-sessions", Key={"session_id": {"S": session_id}}
|
||||
)
|
||||
assert "Item" in sitem, "session not written to nova-sessions"
|
||||
assert sitem["Item"]["user_id"]["S"]
|
||||
assert int(sitem["Item"]["expires_at"]["N"]) > 0
|
||||
|
||||
def test_sign_in_wrong_password_returns_401(self, moto_idp_tables):
|
||||
"""Negative: wrong password → 401 (no user enumeration)."""
|
||||
idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "carol@example.com",
|
||||
"password": "Correct-1",
|
||||
"owner": "owner-carol",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "carol@example.com",
|
||||
"password": "Wrong-2",
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 401, resp
|
||||
body = json.loads(resp["body"])
|
||||
assert body["error"] == "invalid_credentials"
|
||||
|
||||
def test_sign_up_duplicate_email_returns_409(self, moto_idp_tables):
|
||||
"""Negative: duplicate email → 409."""
|
||||
payload = {
|
||||
"action": "sign_up",
|
||||
"email": "dup@example.com",
|
||||
"password": "First-1",
|
||||
"owner": "owner-dup",
|
||||
"roles": ["user"],
|
||||
}
|
||||
first = idp.lambda_handler({"body": json.dumps(payload)}, None)
|
||||
assert first["statusCode"] == 200, first
|
||||
second = idp.lambda_handler({"body": json.dumps(payload)}, None)
|
||||
assert second["statusCode"] == 409, second
|
||||
assert json.loads(second["body"])["error"] == "email_already_registered"
|
||||
|
||||
def test_sign_in_unknown_email_returns_401(self, moto_idp_tables):
|
||||
"""Unknown email → 401 (same as wrong password, no enumeration)."""
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "nobody@example.com",
|
||||
"password": "x",
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 401, resp
|
||||
|
||||
def test_create_session_standalone(self, moto_idp_tables):
|
||||
"""create_session action writes a session row."""
|
||||
resp = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "create_session", "user_id": "u-xyz"})},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
sid = json.loads(resp["body"])["session_id"]
|
||||
item = moto_idp_tables.get_item(
|
||||
TableName="nova-sessions", Key={"session_id": {"S": sid}}
|
||||
)
|
||||
assert "Item" in item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-closed (also covered in test_argon2_fail_closed.py, but verify E2E)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFailClosedE2E:
|
||||
def test_sign_up_503_when_argon2_unavailable(self, moto_idp_tables):
|
||||
"""E2E fail-closed: argon2 unavailable → sign_up returns 503 and
|
||||
does NOT write a user (no weak hash write)."""
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "fail@example.com",
|
||||
"password": "p",
|
||||
"owner": "o",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 503, resp
|
||||
# No user should have been written.
|
||||
items = moto_idp_tables.scan(TableName="nova-users").get("Items", [])
|
||||
assert not items, "user was written despite argon2 unavailable (weak hash!)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password reset flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPasswordReset:
|
||||
def test_request_then_reset_password(self, moto_idp_tables):
|
||||
password = "Original-1"
|
||||
idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "reset@example.com",
|
||||
"password": password,
|
||||
"owner": "owner-reset",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
# request reset
|
||||
req = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "request_password_reset",
|
||||
"email": "reset@example.com"})},
|
||||
None,
|
||||
)
|
||||
assert req["statusCode"] == 200, req
|
||||
token = json.loads(req["body"])["reset_token"]
|
||||
assert token, "no reset token returned"
|
||||
# reset password
|
||||
new_pw = "NewSecret-2"
|
||||
rst = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "reset_password",
|
||||
"reset_token": token,
|
||||
"new_password": new_pw})},
|
||||
None,
|
||||
)
|
||||
assert rst["statusCode"] == 200, rst
|
||||
# sign in with the new password works
|
||||
inn = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "sign_in",
|
||||
"email": "reset@example.com",
|
||||
"password": new_pw})},
|
||||
None,
|
||||
)
|
||||
assert inn["statusCode"] == 200, inn
|
||||
# old password now fails
|
||||
old = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "sign_in",
|
||||
"email": "reset@example.com",
|
||||
"password": password})},
|
||||
None,
|
||||
)
|
||||
assert old["statusCode"] == 401, old
|
||||
|
||||
def test_reset_with_invalid_token_returns_400(self, moto_idp_tables):
|
||||
resp = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "reset_password",
|
||||
"reset_token": "bogus",
|
||||
"new_password": "x"})},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 400, resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No raw passwords in logs (verification step 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoRawPasswordsInLogs:
|
||||
def test_sign_up_does_not_log_password(self, moto_idp_tables, caplog):
|
||||
"""Verification step 5: the password string is NOT in any log record."""
|
||||
import logging
|
||||
|
||||
secret = "LogSecret-55512"
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "log@example.com",
|
||||
"password": secret,
|
||||
"owner": "owner-log",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
for record in caplog.records:
|
||||
assert secret not in record.getMessage(), (
|
||||
f"raw password leaked in log: {record.getMessage()!r}"
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""nova idp setup tests (REQ-340, REQ-341, C-2.1).
|
||||
|
||||
Tests:
|
||||
* ``generate_template()`` produces a valid CFN dict with the expected
|
||||
resource types (3 Lambdas, 4 DDB tables, KMS key, 3 URLs, 3 roles).
|
||||
* ``--check`` (mock AWS) → prints a prerequisite report.
|
||||
* ``--dry-run`` → resource summary.
|
||||
* ``--apply`` (mock cloudformation deploy) → prompts + deploys.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
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("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
|
||||
def _load(mod_name, rel_path):
|
||||
spec = importlib.util.spec_from_file_location(mod_name, rel_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_CFN_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_cfn.py"
|
||||
_SETUP_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_setup.py"
|
||||
cfn = _load("nova_idp_cfn_test", _CFN_PATH)
|
||||
setup = _load("nova_idp_setup_test", _SETUP_PATH)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_template_has_expected_resources():
|
||||
t = cfn.generate_template()
|
||||
res = t["Resources"]
|
||||
types = [r["Type"] for r in res.values()]
|
||||
assert types.count("AWS::Lambda::Function") == 3
|
||||
assert types.count("AWS::DynamoDB::Table") == 4
|
||||
assert types.count("AWS::KMS::Key") == 1
|
||||
assert types.count("AWS::KMS::Alias") == 1
|
||||
assert types.count("AWS::Lambda::Url") == 3
|
||||
assert types.count("AWS::IAM::Role") == 3
|
||||
|
||||
|
||||
def test_generate_template_kms_key_spec():
|
||||
t = cfn.generate_template()
|
||||
key = t["Resources"]["NovaOidcSigningKey"]["Properties"]
|
||||
assert key["KeySpec"] == "ECC_NIST_P256"
|
||||
assert key["KeyUsage"] == "SIGN_VERIFY"
|
||||
|
||||
|
||||
def test_generate_template_jwks_url_auth_none():
|
||||
"""JWKS function URL is AuthType NONE (public, REQ-338)."""
|
||||
t = cfn.generate_template()
|
||||
url = t["Resources"]["NovaIdpJwksUrl"]["Properties"]
|
||||
assert url["AuthType"] == "NONE"
|
||||
|
||||
|
||||
def test_generate_template_auth_url_iam():
|
||||
t = cfn.generate_template()
|
||||
url = t["Resources"]["NovaIdpAuthUrl"]["Properties"]
|
||||
assert url["AuthType"] == "AWS_IAM"
|
||||
|
||||
|
||||
def test_generate_template_public_domain_adds_cloudfront():
|
||||
t = cfn.generate_template(public_jwks_domain="jwks.example.com")
|
||||
types = [r["Type"] for r in t["Resources"].values()]
|
||||
assert "AWS::CloudFront::Distribution" in types
|
||||
assert "AWS::CertificateManager::Certificate" in types
|
||||
|
||||
|
||||
def test_resource_summary():
|
||||
t = cfn.generate_template()
|
||||
s = cfn.resource_summary(t)
|
||||
assert s["AWS::Lambda::Function"] == 3
|
||||
assert s["AWS::DynamoDB::Table"] == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_prerequisites_returns_report():
|
||||
with mock.patch("subprocess.check_output", side_effect=Exception("no creds")):
|
||||
report = setup.check_prerequisites()
|
||||
assert "aws_creds" in report
|
||||
assert report["aws_creds"] is False
|
||||
assert "missing" in report
|
||||
assert "iam_delta" in report
|
||||
assert "cloudformation:*" in report["iam_delta"]
|
||||
|
||||
|
||||
def test_check_prerequisites_with_creds():
|
||||
fake = json.dumps({"Account": "123456789012", "UserId": "u", "Arn": "arn"})
|
||||
with mock.patch("subprocess.check_output", return_value=fake):
|
||||
report = setup.check_prerequisites()
|
||||
assert report["aws_creds"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --dry-run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dry_run_returns_summary():
|
||||
r = setup.generate_and_deploy(dry_run=True)
|
||||
assert r["deployed"] is False
|
||||
assert "AWS::Lambda::Function" in r["summary"]
|
||||
assert r["summary"]["AWS::Lambda::Function"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --apply (mock cloudformation deploy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_aborts_without_approval():
|
||||
r = setup.generate_and_deploy(approve_fn=lambda: False)
|
||||
assert r["deployed"] is False
|
||||
|
||||
|
||||
def test_apply_deploys_with_approval():
|
||||
with mock.patch("subprocess.check_call", return_value=0):
|
||||
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||
assert r["deployed"] is True
|
||||
|
||||
|
||||
def test_apply_deploy_failure_returns_not_deployed():
|
||||
with mock.patch("subprocess.check_call", side_effect=RuntimeError("cfn error")):
|
||||
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||
assert r["deployed"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --verify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_roundtrip_passes():
|
||||
r = setup.verify()
|
||||
assert r["passed"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI wrapper (nova/idp/setup.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_setup_check(capsys):
|
||||
from nova.idp import setup as cli_setup
|
||||
args = mock.MagicMock()
|
||||
args.check = True; args.apply = False; args.verify = False; args.dry_run = False
|
||||
args.public_jwks_domain = None
|
||||
rc = cli_setup.run(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "aws_creds" in out
|
||||
|
||||
|
||||
def test_cli_setup_dry_run(capsys):
|
||||
from nova.idp import setup as cli_setup
|
||||
args = mock.MagicMock()
|
||||
args.check = False; args.apply = False; args.verify = False; args.dry_run = True
|
||||
args.public_jwks_domain = None
|
||||
rc = cli_setup.run(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "AWS::Lambda::Function" in out
|
||||
|
||||
|
||||
def test_cli_setup_verify(capsys):
|
||||
from nova.idp import setup as cli_setup
|
||||
args = mock.MagicMock()
|
||||
args.check = False; args.apply = False; args.verify = True; args.dry_run = False
|
||||
args.public_jwks_domain = None
|
||||
rc = cli_setup.run(args)
|
||||
assert rc == 0
|
||||
@@ -0,0 +1,217 @@
|
||||
"""nova idp setup terraform-delegation tests (REQ-369, spec §7.5).
|
||||
|
||||
P3 Wave 2: verifies the ``nova idp setup --apply`` / ``--verify`` paths
|
||||
delegate to ``terraform apply -auto-approve`` / ``terraform plan`` when
|
||||
``terraform`` is on PATH, and fall back to the archived CFN path
|
||||
(emitting a ``DeprecationWarning``) when terraform is absent.
|
||||
|
||||
Mirrors the importlib loading + ``mock.patch``/``monkeypatch`` style of
|
||||
``tests/test_idp_setup.py`` (``lambda`` is a Python reserved word).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
def _load(mod_name, rel_path):
|
||||
spec = importlib.util.spec_from_file_location(mod_name, rel_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_SETUP_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_setup.py"
|
||||
setup = _load("nova_idp_setup_tf_test", _SETUP_PATH)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# core/lambda/nova_idp_setup.py — terraform_apply / terraform_plan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTerraformApply:
|
||||
def test_apply_invokes_terraform_apply_auto_approve(self, monkeypatch):
|
||||
"""terraform_apply shells out to ``terraform apply -auto-approve``."""
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
r = setup.terraform_apply()
|
||||
assert called["cmd"] == ["terraform", "apply", "-auto-approve"]
|
||||
assert r["deployed"] is True
|
||||
assert r["returncode"] == 0
|
||||
assert r["command"] == ["terraform", "apply", "-auto-approve"]
|
||||
|
||||
def test_apply_auto_approve_false_omits_flag(self, monkeypatch):
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
setup.terraform_apply(auto_approve=False)
|
||||
assert called["cmd"] == ["terraform", "apply"]
|
||||
|
||||
def test_apply_nonzero_returncode_means_not_deployed(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
setup.subprocess, "run", lambda cmd, **kw: mock.MagicMock(returncode=1)
|
||||
)
|
||||
r = setup.terraform_apply()
|
||||
assert r["deployed"] is False
|
||||
assert r["returncode"] == 1
|
||||
|
||||
|
||||
class TestTerraformPlan:
|
||||
def test_plan_invokes_terraform_plan(self, monkeypatch):
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
r = setup.terraform_plan()
|
||||
assert called["cmd"] == ["terraform", "plan"]
|
||||
assert r["passed"] is True
|
||||
assert r["command"] == ["terraform", "plan"]
|
||||
|
||||
def test_plan_nonzero_returncode_means_not_passed(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
setup.subprocess, "run", lambda cmd, **kw: mock.MagicMock(returncode=2)
|
||||
)
|
||||
r = setup.terraform_plan()
|
||||
assert r["passed"] is False
|
||||
assert r["returncode"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_and_deploy emits DeprecationWarning (CFN fallback path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCfnFallbackDeprecation:
|
||||
def test_generate_and_deploy_warns_on_cfn_path(self):
|
||||
"""The archived CFN deploy path raises DeprecationWarning (REQ-369)."""
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
with mock.patch("subprocess.check_call", return_value=0):
|
||||
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||
assert r["deployed"] is True
|
||||
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert len(dep) == 1, f"expected one DeprecationWarning, got {dep}"
|
||||
assert "CFN path is archived" in str(dep[0].message)
|
||||
assert "docs/archive/nova-idp-cfn-v1.28.md" in str(dep[0].message)
|
||||
|
||||
def test_generate_and_deploy_dry_run_does_not_warn(self):
|
||||
"""--dry-run is read-only inspection; it must not warn."""
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
r = setup.generate_and_deploy(dry_run=True)
|
||||
assert r["deployed"] is False
|
||||
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert dep == [], f"dry-run must not emit DeprecationWarning, got {dep}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nova/idp/setup.py CLI wrapper — terraform delegation vs CFN fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cli_args(**kw):
|
||||
"""Build a MagicMock mimicking the argparse Namespace for `nova idp setup`."""
|
||||
a = mock.MagicMock()
|
||||
a.check = kw.get("check", False)
|
||||
a.apply = kw.get("apply", False)
|
||||
a.verify = kw.get("verify", False)
|
||||
a.dry_run = kw.get("dry_run", False)
|
||||
a.public_jwks_domain = kw.get("public_jwks_domain", None)
|
||||
return a
|
||||
|
||||
|
||||
class TestCliApplyDelegation:
|
||||
def test_apply_delegates_to_terraform_when_on_path(self, monkeypatch, capsys):
|
||||
"""terraform on PATH → --apply runs `terraform apply -auto-approve`."""
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/terraform" if name == "terraform" else None)
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
from nova.idp import setup as cli_setup
|
||||
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: "/usr/bin/terraform" if name == "terraform" else None)
|
||||
# Patch subprocess.run inside the loaded core module (used by terraform_apply).
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
rc = cli_setup.run(_cli_args(apply=True))
|
||||
assert rc == 0
|
||||
assert called["cmd"] == ["terraform", "apply", "-auto-approve"]
|
||||
out = capsys.readouterr().out
|
||||
assert "deployed" in out
|
||||
|
||||
def test_apply_falls_back_to_cfn_when_terraform_absent(self, monkeypatch, capsys):
|
||||
"""terraform absent → --apply falls back to the CFN path + warns."""
|
||||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||||
from nova.idp import setup as cli_setup
|
||||
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: None)
|
||||
# Stub the CFN deploy so it succeeds without touching aws CLI; answer
|
||||
# the NFR-10 y/N prompt (the CLI path has no approve_fn hook).
|
||||
monkeypatch.setattr("subprocess.check_call", return_value=0)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **kw: "y")
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
rc = cli_setup.run(_cli_args(apply=True))
|
||||
assert rc == 0
|
||||
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert len(dep) == 1, f"expected DeprecationWarning on CFN fallback, got {dep}"
|
||||
assert "docs/archive/nova-idp-cfn-v1.28.md" in str(dep[0].message)
|
||||
out = capsys.readouterr().out
|
||||
assert "AWS::Lambda::Function" in out # CFN resource summary printed
|
||||
|
||||
|
||||
class TestCliVerifyDelegation:
|
||||
def test_verify_delegates_to_terraform_plan_when_on_path(self, monkeypatch, capsys):
|
||||
"""terraform on PATH → --verify runs `terraform plan`."""
|
||||
from nova.idp import setup as cli_setup
|
||||
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: "/usr/bin/terraform" if name == "terraform" else None)
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
rc = cli_setup.run(_cli_args(verify=True))
|
||||
assert rc == 0
|
||||
assert called["cmd"] == ["terraform", "plan"]
|
||||
out = capsys.readouterr().out
|
||||
assert "passed" in out
|
||||
|
||||
def test_verify_falls_back_to_kms_roundtrip_when_terraform_absent(self, monkeypatch, capsys):
|
||||
"""terraform absent → --verify falls back to the existing KMS round-trip."""
|
||||
from nova.idp import setup as cli_setup
|
||||
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: None)
|
||||
# The CLI loads core/lambda/nova_idp_setup.py into its own module
|
||||
# instance; stub _load_setup so verify() is deterministic and does
|
||||
# not require pyjwt/cryptography (the real round-trip is covered by
|
||||
# tests/test_idp_setup.py).
|
||||
fake_mod = mock.MagicMock()
|
||||
fake_mod.verify.return_value = {"passed": True, "detail": "KMS round-trip OK"}
|
||||
monkeypatch.setattr(cli_setup, "_load_setup", lambda: fake_mod)
|
||||
rc = cli_setup.run(_cli_args(verify=True))
|
||||
assert rc == 0
|
||||
fake_mod.verify.assert_called_once()
|
||||
out = capsys.readouterr().out
|
||||
assert "passed" in out # KMS round-trip result printed
|
||||
@@ -0,0 +1,50 @@
|
||||
"""REQ-331 test: nova init scaffolds .nova/contract.yml.attestations/ empty.
|
||||
|
||||
P1 (nova/init.py + core/init_scaffold.py) creates the attestations dir
|
||||
during `nova init`. This test explicitly verifies (a) the dir exists and
|
||||
(b) it is EMPTY after init (listdir returns []) — a freshly scaffolded
|
||||
repo has no attestations yet (they are produced later by
|
||||
nova apply --sign-local-review / the JWS attestation flow, REQ-332).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _nova_help_cmd():
|
||||
"""Return the command list to invoke `nova --help` (prefer installed entry)."""
|
||||
nova = os.path.join(os.path.dirname(sys.executable), "nova")
|
||||
if os.path.isfile(nova):
|
||||
return [nova, "--help"]
|
||||
return [sys.executable, "-m", "nova.cli", "--help"]
|
||||
|
||||
|
||||
def test_init_attestations_dir_exists_and_is_empty(tmp_path):
|
||||
"""nova init creates .nova/contract.yml.attestations/ and it is empty."""
|
||||
cmd = _nova_help_cmd()
|
||||
init_cmd = cmd[:-1] + ["init"]
|
||||
proc = subprocess.run(init_cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert proc.returncode == 0, f"nova init failed: {proc.stderr}"
|
||||
attest_dir = tmp_path / ".nova" / "contract.yml.attestations"
|
||||
assert attest_dir.is_dir(), ".nova/contract.yml.attestations/ not created"
|
||||
# REQ-331: the dir is empty after init (no attestations yet).
|
||||
entries = os.listdir(attest_dir)
|
||||
assert entries == [], (
|
||||
f".nova/contract.yml.attestations/ not empty after init: {entries}"
|
||||
)
|
||||
|
||||
|
||||
def test_init_attestations_dir_is_a_directory_not_a_file(tmp_path):
|
||||
"""The attestations path is a directory (not a file), so attestation
|
||||
JWS files can be written into it later (REQ-332 flow)."""
|
||||
cmd = _nova_help_cmd()
|
||||
init_cmd = cmd[:-1] + ["init"]
|
||||
proc = subprocess.run(init_cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert proc.returncode == 0, f"nova init failed: {proc.stderr}"
|
||||
attest_path = tmp_path / ".nova" / "contract.yml.attestations"
|
||||
assert attest_path.is_dir(), f"{attest_path} is not a directory"
|
||||
assert not attest_path.is_file(), f"{attest_path} is a file, not a directory"
|
||||
@@ -0,0 +1,123 @@
|
||||
"""JWKS endpoint tests (REQ-338, D-230).
|
||||
|
||||
Mocks ``kms.get_public_key`` with a test ECDSA P-256 public key DER →
|
||||
asserts the Lambda returns 200 + the right headers + a valid JWK.
|
||||
Cross-verifies: a JWT signed with the test private key verifies with
|
||||
pyjwt using the JWKS key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_jwks.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_jwks", _SOURCE_PATH)
|
||||
jwks = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(jwks)
|
||||
|
||||
import core.kms_signing as kms_signing
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
|
||||
|
||||
class _MockKms:
|
||||
def __init__(self, pub_der):
|
||||
self._pub_der = pub_der
|
||||
|
||||
def get_public_key(self, KeyId):
|
||||
return {"PublicKey": self._pub_der, "KeyId": KeyId}
|
||||
|
||||
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||
# Provided so sign_jwt works in the cross-verify test.
|
||||
return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||
|
||||
|
||||
@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():
|
||||
yield
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
|
||||
def test_jwks_returns_200_and_headers(test_keypair):
|
||||
_priv, _pub, pub_der = test_keypair
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(pub_der))
|
||||
resp = jwks.lambda_handler({}, None)
|
||||
assert resp["statusCode"] == 200
|
||||
headers = resp["headers"]
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
assert headers["Cache-Control"] == "public, max-age=3600"
|
||||
assert headers["Access-Control-Allow-Origin"] == "*"
|
||||
|
||||
|
||||
def test_jwks_returns_valid_ec_jwk(test_keypair):
|
||||
_priv, _pub, pub_der = test_keypair
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(pub_der))
|
||||
resp = jwks.lambda_handler({}, None)
|
||||
body = json.loads(resp["body"])
|
||||
assert "keys" in body
|
||||
assert len(body["keys"]) == 1
|
||||
jwk = body["keys"][0]
|
||||
assert jwk["kty"] == "EC"
|
||||
assert jwk["crv"] == "P-256"
|
||||
assert "kid" in jwk
|
||||
assert "x" in jwk and "y" in jwk
|
||||
assert len(jwk["x"]) == 43 # 32 bytes → 43 base64url chars
|
||||
assert len(jwk["y"]) == 43
|
||||
|
||||
|
||||
def test_jwks_cross_verifies_jwt(test_keypair):
|
||||
"""A JWT signed with the test private key verifies with the JWKS key."""
|
||||
priv, _pub, pub_der = test_keypair
|
||||
# Mock KMS that can both sign (for sign_jwt) and serve the public key.
|
||||
mock_kms = _MockKms(pub_der)
|
||||
mock_kms._priv = priv
|
||||
kms_signing.set_kms_client_for_testing(mock_kms)
|
||||
|
||||
# Sign a JWT via kms_signing.sign_jwt.
|
||||
token = kms_signing.sign_jwt(
|
||||
{"sub": "user-1", "exp": 9999999999, "iat": 1, "jti": "j", "aud": "nova-cli"},
|
||||
key_id="alias/nova-oidc-signing",
|
||||
)
|
||||
# Fetch the JWKS via the Lambda.
|
||||
resp = jwks.lambda_handler({}, None)
|
||||
jwk = json.loads(resp["body"])["keys"][0]
|
||||
# Verify the JWT with pyjwt using the JWK.
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False})
|
||||
assert decoded["sub"] == "user-1"
|
||||
assert decoded["jti"] == "j"
|
||||
|
||||
|
||||
def test_jwks_500_on_kms_error():
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
# Force get_jwk to raise by using a broken client.
|
||||
broken = mock.MagicMock()
|
||||
broken.get_public_key.side_effect = RuntimeError("KMS down")
|
||||
kms_signing.set_kms_client_for_testing(broken)
|
||||
resp = jwks.lambda_handler({}, None)
|
||||
assert resp["statusCode"] == 500
|
||||
@@ -0,0 +1,193 @@
|
||||
"""REQ-332 / C-5.2 tests: JWS-from-PAT key derivation (symmetric HS256).
|
||||
|
||||
Verifies:
|
||||
- HKDF-SHA256 key derivation (32 bytes, deterministic, salt/info constants)
|
||||
- sign → verify round-trip (payload matches)
|
||||
- tamper detection (modify the JWS → verify raises)
|
||||
- wrong-PAT detection (verify with a different PAT → raises)
|
||||
- INV-14..17: key derived from PAT, not cached, fixed salt/info, HMAC
|
||||
constant-time comparison
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.jws_attestation import (
|
||||
JWSValidationError,
|
||||
derive_signing_key,
|
||||
sign_attestation,
|
||||
verify_attestation,
|
||||
)
|
||||
|
||||
|
||||
class TestDeriveSigningKey:
|
||||
def test_returns_32_bytes(self):
|
||||
key = derive_signing_key("test-pat")
|
||||
assert isinstance(key, bytes)
|
||||
assert len(key) == 32, f"expected 32 bytes, got {len(key)}"
|
||||
|
||||
def test_deterministic(self):
|
||||
"""The same PAT always yields the same key (HKDF is deterministic)."""
|
||||
k1 = derive_signing_key("my-pat")
|
||||
k2 = derive_signing_key("my-pat")
|
||||
assert k1 == k2
|
||||
|
||||
def test_different_pats_yield_different_keys(self):
|
||||
k1 = derive_signing_key("pat-a")
|
||||
k2 = derive_signing_key("pat-b")
|
||||
assert k1 != k2
|
||||
|
||||
def test_empty_pat_raises(self):
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
derive_signing_key("")
|
||||
|
||||
def test_non_string_pat_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
derive_signing_key(12345) # type: ignore[arg-type]
|
||||
|
||||
def test_key_is_not_the_pat_raw_bytes(self):
|
||||
"""INV-14: the key is DERIVED from the PAT, not the PAT bytes."""
|
||||
key = derive_signing_key("test-pat")
|
||||
assert key != b"test-pat"
|
||||
assert key != "test-pat".encode()
|
||||
|
||||
def test_hashlib_fallback_matches_cryptography(self):
|
||||
"""The hashlib HKDF fallback produces the same key as cryptography."""
|
||||
from core.jws_attestation import _hkdf_sha256, _hkdf_sha256_hashlib
|
||||
ikm = b"test-pat"
|
||||
salt = b"nova-local-attestation"
|
||||
info = b"jws-signing-key"
|
||||
via_crypto = _hkdf_sha256(ikm, salt, info, 32)
|
||||
via_hashlib = _hkdf_sha256_hashlib(ikm, salt, info, 32)
|
||||
assert via_crypto == via_hashlib
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
def test_sign_verify_roundtrip(self):
|
||||
"""sign → verify → payload matches the original."""
|
||||
payload = {"x": 1, "contractId": "c-001", "reviewer": "alice"}
|
||||
jws = sign_attestation(payload, "test-pat")
|
||||
assert isinstance(jws, str)
|
||||
# Compact JWS: 3 dot-separated segments.
|
||||
assert jws.count(".") == 2
|
||||
verified = verify_attestation(jws, "test-pat")
|
||||
assert verified == payload
|
||||
|
||||
def test_roundtrip_complex_payload(self):
|
||||
payload = {
|
||||
"contractId": "msvc-001",
|
||||
"environment": "dev",
|
||||
"reviewers": ["alice", "bob"],
|
||||
"score": 0.92,
|
||||
"nested": {"a": 1, "b": [2, 3]},
|
||||
}
|
||||
jws = sign_attestation(payload, "secret-pat-123")
|
||||
verified = verify_attestation(jws, "secret-pat-123")
|
||||
assert verified == payload
|
||||
|
||||
def test_header_is_hs256_jwt(self):
|
||||
"""The JWS header is {"alg":"HS256","typ":"JWT"}."""
|
||||
import base64
|
||||
import json
|
||||
jws = sign_attestation({"x": 1}, "pat")
|
||||
header_segment = jws.split(".")[0]
|
||||
pad = "=" * (-len(header_segment) % 4)
|
||||
header = json.loads(base64.urlsafe_b64decode(header_segment + pad))
|
||||
assert header["alg"] == "HS256"
|
||||
assert header["typ"] == "JWT"
|
||||
|
||||
|
||||
class TestTamperDetection:
|
||||
def test_tampered_payload_raises(self):
|
||||
"""Modifying the payload segment → verify raises (INV-17)."""
|
||||
payload = {"x": 1}
|
||||
jws = sign_attestation(payload, "test-pat")
|
||||
parts = jws.split(".")
|
||||
# Flip a char in the payload segment.
|
||||
tampered_payload = parts[1][:-1] + ("A" if parts[1][-1] != "A" else "B")
|
||||
tampered = f"{parts[0]}.{tampered_payload}.{parts[2]}"
|
||||
with pytest.raises(JWSValidationError, match="signature verification failed"):
|
||||
verify_attestation(tampered, "test-pat")
|
||||
|
||||
def test_tampered_signature_raises(self):
|
||||
"""Modifying the signature segment → verify raises."""
|
||||
payload = {"x": 1}
|
||||
jws = sign_attestation(payload, "test-pat")
|
||||
parts = jws.split(".")
|
||||
tampered_sig = parts[2][:-1] + ("A" if parts[2][-1] != "A" else "B")
|
||||
tampered = f"{parts[0]}.{parts[1]}.{tampered_sig}"
|
||||
with pytest.raises(JWSValidationError, match="signature verification failed"):
|
||||
verify_attestation(tampered, "test-pat")
|
||||
|
||||
def test_tampered_header_raises(self):
|
||||
"""Modifying the header segment → verify raises (header is part of
|
||||
the signing input)."""
|
||||
payload = {"x": 1}
|
||||
jws = sign_attestation(payload, "test-pat")
|
||||
parts = jws.split(".")
|
||||
tampered_header = parts[0][:-1] + ("A" if parts[0][-1] != "A" else "B")
|
||||
tampered = f"{tampered_header}.{parts[1]}.{parts[2]}"
|
||||
with pytest.raises(JWSValidationError):
|
||||
verify_attestation(tampered, "test-pat")
|
||||
|
||||
def test_malformed_jws_raises(self):
|
||||
with pytest.raises(JWSValidationError, match="3 segments"):
|
||||
verify_attestation("not.a.jws.token", "pat")
|
||||
with pytest.raises(JWSValidationError, match="3 segments"):
|
||||
verify_attestation("onlyonesegment", "pat")
|
||||
|
||||
|
||||
class TestWrongPatDetection:
|
||||
def test_wrong_pat_raises(self):
|
||||
"""Verify with a different PAT → raises (the key derivation differs)."""
|
||||
payload = {"x": 1}
|
||||
jws = sign_attestation(payload, "correct-pat")
|
||||
with pytest.raises(JWSValidationError, match="signature verification failed"):
|
||||
verify_attestation(jws, "wrong-pat")
|
||||
|
||||
def test_empty_pat_raises(self):
|
||||
jws = sign_attestation({"x": 1}, "real-pat")
|
||||
with pytest.raises(ValueError):
|
||||
verify_attestation(jws, "")
|
||||
|
||||
|
||||
class TestInvInvariants:
|
||||
def test_inv14_key_derived_from_pat(self):
|
||||
"""INV-14: the signing key is derived from the PAT via HKDF."""
|
||||
# The key is a function of the PAT (different PAT → different key,
|
||||
# same PAT → same key). Already covered above; this is the explicit
|
||||
# invariant assertion.
|
||||
assert derive_signing_key("pat") == derive_signing_key("pat")
|
||||
assert derive_signing_key("pat") != derive_signing_key("other")
|
||||
|
||||
def test_inv15_key_not_cached(self):
|
||||
"""INV-15: derive_signing_key recomputes the key on each call (no
|
||||
module-level cache of the key). Inspect the module source."""
|
||||
import inspect
|
||||
from core import jws_attestation
|
||||
src = inspect.getsource(jws_attestation.derive_signing_key)
|
||||
assert "_hkdf_sha256(" in src
|
||||
# No module-level key cache variable.
|
||||
assert not hasattr(jws_attestation, "_cached_key")
|
||||
assert not hasattr(jws_attestation, "_signing_key")
|
||||
|
||||
def test_inv16_salt_and_info_are_fixed_constants(self):
|
||||
"""INV-16: the salt + info are fixed constants binding the key to
|
||||
the nova-local-attestation / jws-signing-key purpose."""
|
||||
from core import jws_attestation
|
||||
assert jws_attestation._KDF_SALT == b"nova-local-attestation"
|
||||
assert jws_attestation._KDF_INFO == b"jws-signing-key"
|
||||
assert jws_attestation._KDF_LENGTH == 32
|
||||
|
||||
def test_inv17_constant_time_comparison(self):
|
||||
"""INV-17: signature comparison uses hmac.compare_digest (constant-time)."""
|
||||
import inspect
|
||||
from core import jws_attestation
|
||||
src = inspect.getsource(jws_attestation.verify_attestation)
|
||||
assert "compare_digest" in src
|
||||
@@ -0,0 +1,145 @@
|
||||
"""CAP-037 KMS round-trip test (REQ-350).
|
||||
|
||||
Sign a test JWT via ``core.kms_signing.sign_jwt()`` (mock KMS with a
|
||||
test keypair) → fetch JWKS via ``nova_idp_jwks.lambda_handler()`` (mock
|
||||
KMS) → verify the JWT with ``pyjwt`` using the JWKS key. Round-trip
|
||||
succeeds — proves the DER→raw conversion + JWK export are mutually
|
||||
consistent (the #1 gotcha from RESEARCH §5).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
_JWKS_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_jwks.py"
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_jwks_rt", _JWKS_PATH)
|
||||
jwks_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(jwks_mod)
|
||||
|
||||
import core.kms_signing as kms_signing
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset():
|
||||
yield
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
|
||||
def test_cap037_kms_roundtrip():
|
||||
"""Sign JWT → JWKS → pyjwt verify. The full KMS round-trip (REQ-350)."""
|
||||
priv = ec.generate_private_key(ec.SECP256R1())
|
||||
pub = priv.public_key()
|
||||
pub_der = pub.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||
|
||||
# 1. Sign a JWT via kms_signing.sign_jwt (uses DER→raw conversion).
|
||||
claims = {
|
||||
"sub": "roundtrip-user", "aud": "nova-cli", "iss": "nova-idp",
|
||||
"exp": 9999999999, "iat": 1700000000, "jti": "rt-jti",
|
||||
"roles": ["developer"], "typ": "nova_oidc_token",
|
||||
}
|
||||
token = kms_signing.sign_jwt(claims, key_id="alias/nova-oidc-signing")
|
||||
|
||||
# 2. Fetch the JWKS via the JWKS Lambda (mock KMS get_public_key).
|
||||
resp = jwks_mod.lambda_handler({}, None)
|
||||
assert resp["statusCode"] == 200
|
||||
jwks_body = json.loads(resp["body"])
|
||||
jwk = jwks_body["keys"][0]
|
||||
assert jwk["kty"] == "EC" and jwk["crv"] == "P-256"
|
||||
|
||||
# 3. Verify the JWT with pyjwt using the JWKS key.
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(token, key, algorithms=["ES256"], audience="nova-cli")
|
||||
assert decoded["sub"] == "roundtrip-user"
|
||||
assert decoded["jti"] == "rt-jti"
|
||||
assert decoded["roles"] == ["developer"]
|
||||
assert decoded["typ"] == "nova_oidc_token"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live-KMS round-trip (REQ-362, Edge 5 item 6).
|
||||
#
|
||||
# This test is marked ``@pytest.mark.live_aws`` and is SKIPPED in acdl CI
|
||||
# (the live KMS key ``alias/nova-oidc-signing`` is not provisioned here).
|
||||
# It runs in nova-platform-ops CI against the real KMS key, REQ-362
|
||||
# (covered-reference — verification surface is the nova-platform-ops
|
||||
# pipeline, not acdl's). It exercises the same sign → JWKS → verify path
|
||||
# against the production key/alias so the DER→raw conversion + JWK export
|
||||
# are verified end-to-end against real AWS KMS.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _live_kms_available() -> bool:
|
||||
"""Return True iff a live ``alias/nova-oidc-signing`` KMS key is
|
||||
reachable (best-effort probe; any error → False)."""
|
||||
try:
|
||||
import boto3
|
||||
client = boto3.client("kms")
|
||||
client.describe_key(KeyId="alias/nova-oidc-signing")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.live_aws
|
||||
def test_cap037_kms_roundtrip_live():
|
||||
"""Sign → JWKS → pyjwt verify against the LIVE KMS key
|
||||
(``alias/nova-oidc-signing``). Edge 5 item 6, REQ-362.
|
||||
|
||||
Skipped unless a live KMS key is reachable (acdl CI has none; this
|
||||
runs in nova-platform-ops CI). The mock-based ``test_cap037_kms_roundtrip``
|
||||
above is the acdl-CI-runnable covered-path.
|
||||
"""
|
||||
if not _live_kms_available():
|
||||
pytest.skip(
|
||||
"live KMS key alias/nova-oidc-signing not reachable "
|
||||
"(acdl CI; runs in nova-platform-ops CI, REQ-362)"
|
||||
)
|
||||
# Use the real KMS client (reset any test-injected mock client).
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
claims = {
|
||||
"sub": "live-roundtrip-user", "aud": "nova-cli", "iss": "nova-idp",
|
||||
"exp": 9999999999, "iat": 1700000000, "jti": "live-rt-jti",
|
||||
"roles": ["developer"], "typ": "nova_oidc_token",
|
||||
}
|
||||
token = kms_signing.sign_jwt(claims, key_id="alias/nova-oidc-signing")
|
||||
|
||||
resp = jwks_mod.lambda_handler({}, None)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
jwk = json.loads(resp["body"])["keys"][0]
|
||||
assert jwk["kty"] == "EC" and jwk["crv"] == "P-256"
|
||||
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(token, key, algorithms=["ES256"], audience="nova-cli")
|
||||
assert decoded["sub"] == "live-roundtrip-user"
|
||||
assert decoded["jti"] == "live-rt-jti"
|
||||
assert decoded["typ"] == "nova_oidc_token"
|
||||
@@ -0,0 +1,172 @@
|
||||
"""KMS signing tests (REQ-337, C-1.1).
|
||||
|
||||
Tests :func:`core.kms_signing.der_to_raw_ecdsa` with a known DER
|
||||
signature and the full :func:`sign_jwt` round-trip with a mocked KMS
|
||||
client (no real AWS calls — C-1.1 documented as a CI gate in
|
||||
``docs/kms-provisioning.md``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import core.kms_signing as kms_signing
|
||||
from core.kms_signing import der_to_raw_ecdsa, sign_jwt, get_jwk
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
import jwt as pyjwt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test keypair — generated once per session (P-256).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_keypair():
|
||||
priv = ec.generate_private_key(ec.SECP256R1())
|
||||
pub = priv.public_key()
|
||||
return priv, pub
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_pub_der(test_keypair):
|
||||
_priv, pub = test_keypair
|
||||
return pub.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
|
||||
class _MockKmsSignClient:
|
||||
"""Mock KMS client that signs with a test ECDSA private key (DER)."""
|
||||
|
||||
def __init__(self, priv, pub_der, key_id="alias/nova-oidc-signing"):
|
||||
self._priv = priv
|
||||
self._pub_der = pub_der
|
||||
self._key_id = key_id
|
||||
|
||||
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||
assert SigningAlgorithm == "ECDSA_SHA_256"
|
||||
assert MessageType == "RAW"
|
||||
der = self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))
|
||||
return {"Signature": der, "KeyId": KeyId}
|
||||
|
||||
def get_public_key(self, KeyId):
|
||||
return {"PublicKey": self._pub_der, "KeyId": KeyId}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# der_to_raw_ecdsa — unit test with a known DER signature.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_der_to_raw_ecdsa_known_vector():
|
||||
# Minimal DER: SEQUENCE { INTEGER r, INTEGER s }.
|
||||
# r=5, s=7 → DER: 30 06 02 01 05 02 01 07
|
||||
der = b"\x30\x06\x02\x01\x05\x02\x01\x07"
|
||||
raw = der_to_raw_ecdsa(der)
|
||||
assert len(raw) == 64 # 32 + 32
|
||||
r = int.from_bytes(raw[:32], "big")
|
||||
s = int.from_bytes(raw[32:], "big")
|
||||
assert r == 5
|
||||
assert s == 7
|
||||
|
||||
|
||||
def test_der_to_raw_ecdsa_real_signature(test_keypair):
|
||||
priv, _ = test_keypair
|
||||
msg = b"test message for der->raw"
|
||||
der = priv.sign(msg, ec.ECDSA(hashes.SHA256()))
|
||||
raw = der_to_raw_ecdsa(der)
|
||||
assert len(raw) == 64
|
||||
# Round-trip: raw → (r, s) should verify against the message.
|
||||
r = int.from_bytes(raw[:32], "big")
|
||||
s = int.from_bytes(raw[32:], "big")
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||||
der2 = encode_dss_signature(r, s)
|
||||
# Verifying with the re-encoded DER proves the raw split is correct.
|
||||
priv.public_key().verify(der2, msg, ec.ECDSA(hashes.SHA256()))
|
||||
|
||||
|
||||
def test_der_to_raw_ecdsa_rejects_oversized_coord():
|
||||
# r needs 33 bytes (2**256+1) → should raise.
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||||
der = encode_dss_signature(2**256 + 1, 1)
|
||||
with pytest.raises(ValueError):
|
||||
der_to_raw_ecdsa(der, coord_len=32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sign_jwt — full round-trip with mock KMS + pyjwt verification.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sign_jwt_roundtrip_verifies_with_pyjwt(test_keypair, test_pub_der):
|
||||
priv, pub = test_keypair
|
||||
client = _MockKmsSignClient(priv, test_pub_der)
|
||||
kms_signing.set_kms_client_for_testing(client)
|
||||
try:
|
||||
claims = {
|
||||
"sub": "user-1",
|
||||
"aud": "nova-cli",
|
||||
"iss": "nova-idp",
|
||||
"exp": 9999999999,
|
||||
"iat": 1700000000,
|
||||
"jti": "test-jti",
|
||||
"roles": ["developer"],
|
||||
}
|
||||
token = sign_jwt(claims, key_id="alias/nova-oidc-signing")
|
||||
parts = token.split(".")
|
||||
assert len(parts) == 3 # header.payload.signature
|
||||
|
||||
# Verify the header.
|
||||
header = json.loads(base64.urlsafe_b64decode(parts[0] + "=="))
|
||||
assert header["alg"] == "ES256"
|
||||
assert header["typ"] == "JWT"
|
||||
assert header["kid"] == "alias/nova-oidc-signing"
|
||||
|
||||
# Verify the signature with pyjwt using the test public key.
|
||||
pem = pub.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
decoded = pyjwt.decode(token, pem, algorithms=["ES256"], options={"verify_aud": False})
|
||||
assert decoded["sub"] == "user-1"
|
||||
assert decoded["jti"] == "test-jti"
|
||||
assert decoded["roles"] == ["developer"]
|
||||
finally:
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
|
||||
def test_get_jwk_returns_valid_ec_jwk(test_keypair, test_pub_der):
|
||||
priv, pub = test_keypair
|
||||
client = _MockKmsSignClient(priv, test_pub_der)
|
||||
kms_signing.set_kms_client_for_testing(client)
|
||||
try:
|
||||
jwk = get_jwk(key_id="alias/nova-oidc-signing")
|
||||
assert jwk["kty"] == "EC"
|
||||
assert jwk["crv"] == "P-256"
|
||||
assert jwk["kid"] == "alias/nova-oidc-signing"
|
||||
assert jwk["alg"] == "ES256"
|
||||
# x and y are 32 bytes → 43 base64url chars (no padding).
|
||||
assert len(jwk["x"]) == 43
|
||||
assert len(jwk["y"]) == 43
|
||||
# Cross-verify: a JWT signed with the test private key verifies
|
||||
# with pyjwt using this JWK as the key.
|
||||
client2 = _MockKmsSignClient(priv, test_pub_der)
|
||||
kms_signing.set_kms_client_for_testing(client2)
|
||||
token = sign_jwt({"sub": "x", "exp": 9999999999, "iat": 1, "jti": "j"})
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False})
|
||||
assert decoded["sub"] == "x"
|
||||
finally:
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""REQ-330 tests: core.env.synthesize_local_env — local env synthesizer.
|
||||
|
||||
Verifies the synthesizer:
|
||||
- reads a contract YAML and produces a local env dict
|
||||
- the dict mirrors the shape of core/environments/*.json (validates
|
||||
against schemas/environment.schema.json)
|
||||
- region is "local" + account_id is the placeholder (no real AWS)
|
||||
- the environment override wins over the contract's environment field
|
||||
- mirrors core/onboarding.py:generate_env_file() shape (same required keys)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.env import synthesize_local_env
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ENV_SCHEMA_PATH = REPO_ROOT / "schemas" / "environment.schema.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_schema():
|
||||
return json.loads(ENV_SCHEMA_PATH.read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_contract(tmp_path):
|
||||
"""A minimal contract YAML for the synthesizer to read."""
|
||||
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
|
||||
|
||||
|
||||
class TestSynthesizeLocalEnv:
|
||||
def test_returns_dict_with_required_keys(self, sample_contract, env_schema):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert isinstance(env, dict)
|
||||
# The schema-required keys.
|
||||
for key in (
|
||||
"name", "account_id", "region", "state_backend",
|
||||
"network", "runner_role_arn", "autonomy", "confidence_threshold",
|
||||
):
|
||||
assert key in env, f"missing required key: {key}"
|
||||
|
||||
def test_validates_against_environment_schema(self, sample_contract, env_schema):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
jsonschema.validate(env, env_schema) # raises on invalid
|
||||
|
||||
def test_region_is_local(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert env["region"] == "local", "region must be the local sentinel"
|
||||
|
||||
def test_account_id_is_placeholder(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert env["account_id"] == "000000000000", (
|
||||
"account_id must be the placeholder (no real AWS account)"
|
||||
)
|
||||
|
||||
def test_state_backend_is_local(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
sb = env["state_backend"]
|
||||
assert sb["bucket"] == "local-tfstate"
|
||||
assert sb["lock_table"] == "local-locks"
|
||||
|
||||
def test_uses_contract_environment_by_default(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert env["name"] == "dev" # the contract's environment field
|
||||
|
||||
def test_environment_override_wins(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract), environment="qa")
|
||||
assert env["name"] == "qa"
|
||||
# qa threshold is 0.75 (per-env default)
|
||||
assert env["confidence_threshold"] == 0.75
|
||||
|
||||
def test_confidence_threshold_per_env(self, sample_contract):
|
||||
for env_name, expected in (("dev", 0.50), ("qa", 0.75), ("prod", 0.90), ("dr", 0.95)):
|
||||
env = synthesize_local_env(str(sample_contract), environment=env_name)
|
||||
assert env["confidence_threshold"] == expected, env_name
|
||||
|
||||
def test_autonomy_is_full(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert env["autonomy"] == "full" # local tier is autonomous
|
||||
|
||||
def test_no_real_aws_resources(self, sample_contract):
|
||||
"""The synthesizer must NOT reference real AWS resources — region
|
||||
is 'local', the ARN uses the placeholder account, the bucket is local."""
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert "us-east-1" not in env["region"]
|
||||
assert "000000000000" in env["runner_role_arn"]
|
||||
assert "local" in env["state_backend"]["bucket"]
|
||||
|
||||
def test_mirrors_onboarding_env_file_shape(self, sample_contract, env_schema):
|
||||
"""The synthesized env has the same core shape as
|
||||
core/onboarding.py:generate_env_file() output — both carry the
|
||||
schema-required environment-binding keys. (generate_env_file adds
|
||||
ownerId/billingTag for the onboarding request path; the synthesizer
|
||||
is the local-tier counterpart and omits those — no consumer binding.)"""
|
||||
from core.onboarding import generate_env_file
|
||||
request = {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
"requestedEnvironment": "dev",
|
||||
"ownerId": "team-a",
|
||||
"billingTag": "cc-a",
|
||||
}
|
||||
onboarded = generate_env_file(request)
|
||||
# The synthesizer output validates against the env schema.
|
||||
synth = synthesize_local_env(str(sample_contract))
|
||||
jsonschema.validate(synth, env_schema)
|
||||
# Both carry the schema-required environment-binding keys.
|
||||
required = {
|
||||
"name", "account_id", "region", "state_backend",
|
||||
"network", "runner_role_arn", "autonomy", "confidence_threshold",
|
||||
}
|
||||
assert required <= set(onboarded.keys()), "onboarding output missing required keys"
|
||||
assert required <= set(synth.keys()), "synthesizer output missing required keys"
|
||||
# The synthesizer omits the onboarding-request-only keys.
|
||||
assert "ownerId" not in synth
|
||||
assert "billingTag" not in synth
|
||||
|
||||
def test_missing_contract_file_defaults_to_dev(self, tmp_path):
|
||||
"""A non-existent contract path defaults to the dev env (no crash)."""
|
||||
env = synthesize_local_env(str(tmp_path / "nonexistent.yml"))
|
||||
assert env["name"] == "dev"
|
||||
assert env["region"] == "local"
|
||||
|
||||
def test_description_mentions_contract_id(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert "msvc" in env["description"], (
|
||||
"description should reference the contract id for traceability"
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Property + edge-case tests for core.mode_resolver (P1, REQ-349).
|
||||
|
||||
Hypothesis-driven: deterministic, flag-wins, invalid-env-ignored,
|
||||
no-silent-fallback, credential+TTY semantics. Edge cases as explicit
|
||||
tests (TTY + piped-stdout analog, missing credential, conflicting
|
||||
flag/env).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, strategies as st, settings, HealthCheck
|
||||
|
||||
from core.mode_resolver import resolve_mode
|
||||
|
||||
|
||||
flag_st = st.sampled_from(["agent", "interactive", None])
|
||||
env_st = st.sampled_from(["agent", "interactive", "auto", "", None])
|
||||
cred_st = st.sampled_from(["developer_pat", "nova_oidc_token", None])
|
||||
tty_st = st.booleans()
|
||||
|
||||
|
||||
@given(flag=flag_st, env=env_st, cred=cred_st, tty=tty_st)
|
||||
@settings(max_examples=200)
|
||||
def test_deterministic(flag, env, cred, tty):
|
||||
a = resolve_mode(flag=flag, env_var=env, credential_type=cred, stdin_isatty=tty)
|
||||
b = resolve_mode(flag=flag, env_var=env, credential_type=cred, stdin_isatty=tty)
|
||||
assert a == b
|
||||
|
||||
|
||||
@given(flag=flag_st, env=env_st, cred=cred_st, tty=tty_st)
|
||||
@settings(max_examples=200)
|
||||
def test_flag_wins(flag, env, cred, tty):
|
||||
mode, reason = resolve_mode(flag=flag, env_var=env, credential_type=cred, stdin_isatty=tty)
|
||||
if flag in ("agent", "interactive"):
|
||||
assert mode == flag
|
||||
assert reason == "flag"
|
||||
|
||||
|
||||
@given(env=env_st, cred=cred_st, tty=tty_st)
|
||||
@settings(max_examples=200, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_invalid_env_ignored(env, cred, tty, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="nova.mode_resolver"):
|
||||
mode, reason = resolve_mode(flag=None, env_var=env, credential_type=cred, stdin_isatty=tty)
|
||||
if env in ("auto", ""):
|
||||
# invalid/empty env must fall through to credential-or-tty result
|
||||
expected_mode, expected_reason = resolve_mode(flag=None, env_var=None, credential_type=cred, stdin_isatty=tty)
|
||||
assert (mode, reason) == (expected_mode, expected_reason)
|
||||
|
||||
|
||||
@given(flag=flag_st, env=env_st, cred=cred_st, tty=tty_st)
|
||||
@settings(max_examples=200)
|
||||
def test_no_silent_fallback(flag, env, cred, tty):
|
||||
_, reason = resolve_mode(flag=flag, env_var=env, credential_type=cred, stdin_isatty=tty)
|
||||
assert reason and reason.strip() != ""
|
||||
|
||||
|
||||
@given(cred=st.sampled_from(["developer_pat", "nova_oidc_token"]), tty=tty_st)
|
||||
@settings(max_examples=100)
|
||||
def test_credential_tty_semantics(cred, tty):
|
||||
mode, reason = resolve_mode(flag=None, env_var=None, credential_type=cred, stdin_isatty=tty)
|
||||
if tty:
|
||||
assert mode == "interactive"
|
||||
else:
|
||||
assert mode == "agent"
|
||||
assert reason == f"credential:{cred}"
|
||||
|
||||
|
||||
# --- Edge cases (explicit) ---
|
||||
|
||||
def test_edge_stdin_tty_true_with_credential_is_interactive():
|
||||
"""Edge 3 analog: stdin is a TTY (even if stdout piped) → interactive."""
|
||||
mode, reason = resolve_mode(flag=None, env_var=None, credential_type="developer_pat", stdin_isatty=True)
|
||||
assert mode == "interactive"
|
||||
assert reason == "credential:developer_pat"
|
||||
|
||||
|
||||
def test_edge_missing_credential_falls_to_tty():
|
||||
mode_no_tty, reason_no = resolve_mode(flag=None, env_var=None, credential_type=None, stdin_isatty=False)
|
||||
mode_tty, reason_tty = resolve_mode(flag=None, env_var=None, credential_type=None, stdin_isatty=True)
|
||||
assert mode_no_tty == "agent" and reason_no == "tty"
|
||||
assert mode_tty == "interactive" and reason_tty == "tty"
|
||||
|
||||
|
||||
def test_edge_conflicting_flag_env_flag_wins():
|
||||
mode, reason = resolve_mode(flag="agent", env_var="interactive", credential_type="developer_pat", stdin_isatty=True)
|
||||
assert mode == "agent" and reason == "flag"
|
||||
|
||||
|
||||
def test_edge_env_wins_over_credential():
|
||||
mode, reason = resolve_mode(flag=None, env_var="agent", credential_type="developer_pat", stdin_isatty=True)
|
||||
assert mode == "agent" and reason == "env"
|
||||
|
||||
|
||||
def test_edge_invalid_env_warns_and_falls_through(caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="nova.mode_resolver"):
|
||||
mode, reason = resolve_mode(flag=None, env_var="auto", credential_type=None, stdin_isatty=False)
|
||||
assert mode == "agent" and reason == "tty"
|
||||
assert any("invalid" in rec.message.lower() for rec in caplog.records)
|
||||
|
||||
|
||||
def test_resolve_mode_from_env_uses_argv_flag(monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", ["nova", "--mode", "interactive", "policy"])
|
||||
monkeypatch.setenv("NOVA_CLIENT_MODE", "agent")
|
||||
from core.mode_resolver import resolve_mode_from_env
|
||||
mode, reason = resolve_mode_from_env(credential_type=None)
|
||||
assert mode == "interactive" and reason == "flag"
|
||||
|
||||
|
||||
def test_resolve_mode_from_env_uses_env_when_no_flag(monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", ["nova", "policy"])
|
||||
monkeypatch.setenv("NOVA_CLIENT_MODE", "interactive")
|
||||
from core.mode_resolver import resolve_mode_from_env
|
||||
mode, reason = resolve_mode_from_env(credential_type=None)
|
||||
assert mode == "interactive" and reason == "env"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user