Compare commits

..

7 Commits

Author SHA1 Message Date
Jon Chery 4697692ce7 docs(ship): P5 complete → v1.27.5 (v1.28 docs-integration)
Nova Slides Render / render (push) Failing after 26s
---ci---
project: acdl
phase: 5
milestone: v1.28
status: complete
---/ci---
2026-08-19 23:34:11 +00:00
Jon Chery 23b8ff81d3 docs(P05): verify REQ-349/350/351 pass in combined suite (security-engineer)
---ci---
project: acdl
phase: 5
milestone: v1.28
status: execute
persona: security-engineer
---
Verification results (combined suite, .venv/bin/python -m pytest):

  REQ-349 (mode_resolver property tests, tests/test_mode_resolver.py):
    12 passed — all four priority levels + edge cases (TTY/piped stdout,
    missing credential, conflicting flag/env, invalid env value).

  REQ-350 (KMS round-trip, tests/test_kms_roundtrip.py): 1 passed —
    sign JWT via mock KMS → JWKS Lambda → pyjwt verify (CAP-037, DER→raw
    byte-correct).

  REQ-351 (PAT revocation SLO, tests/test_pat_revocation.py): 2 passed
    — issue → vend → revoke → 403 pat_revoked in <1s (D-229 strong-read).

  Combined suite regression run
  (pytest tests/ --ignore=tests/test_pptx_generator.py -k 'not live_aws
  and not slow'): 1000 passed, 5 deselected, 0 failed.

Fix: the first regression run flagged 1 failure —
test_no_forge_mentions_in_synced_files (REQ-230 v1.20 guard) caught two
forbidden forge-name mentions in the new docs (operator-guide §7,
developer-guide §9). Rephrased both to 'internal forge' / 'internal
forge runner' to keep the docs sync-safe. No code changes. Re-ran the
full suite: 1000 passed, 0 failed.
2026-08-19 23:30:41 +00:00
Jon Chery d0a8c363b2 test(P05): E2E integration test — sign-up→sign-in→token-vend→apply→audit (REQ-348, security-engineer)
---ci---
project: acdl
phase: 5
milestone: v1.28
status: execute
persona: security-engineer
---
Add tests/test_e2e_idp.py — the J1+J2 happy-path E2E flow. Uses moto
for DynamoDB (4 IdP tables) + mock KMS (test ECC keypair). Asserts:
(a) sign_up succeeds, (b) sign_in returns a session, (c) token-vend
returns a KMS-signed OIDC token, (d) the OIDC token verifies with the
JWKS key (pyjwt), (e) nova apply --local produces a JWS attestation
(HS256), (f) the JWS verifies with the PAT-derived key (+ tamper
detection), (g) the audit chain is complete + linked (auth.sign_up,
auth.sign_in, auth.session_created, pat.issued, token.vend.allowed —
all present, linked by user_id/jti, no raw password/PAT leaked
INV-16). Also: the credentials file stores the OIDC token not the raw
PAT (C-7.3), the DDB user item has a password_hash not the raw
password, the DDB PAT row has a pat_hash not the raw PAT. Negative
path: revocation breaks the chain (403 pat_revoked, D-229 strong-read
SLO, token.vend.denied audit event).
2026-08-19 23:22:53 +00:00
Jon Chery 04053df16e docs(P05): identity-layer threat model (REQ-347, C-6.2, C-9.2, security-engineer)
---ci---
project: acdl
phase: 5
milestone: v1.28
status: execute
persona: security-engineer
---
Add docs/threat-model.md covering 8 threats + mitigations: (T-1) password
compromise → Argon2id + fail-closed (D-228) + no raw passwords (INV-16);
(T-2) PAT theft → credentials.json stores OIDC token not raw PAT (C-7.3)
+ max TTL ≤24h dev/≤1h service-account (C-6.2) + strong-read revocation
(D-229); (T-3) JWKS DDoS → reserved concurrency 10 + 1h client cache +
optional CloudFront/WAF (C-6.2); (T-4) ABAC bypass → fail-closed
(C-6.1/C-7.1, INV-17, 7 tests); (T-5) KMS key compromise → key policy
restricts kms:Sign + 90-day rotation; (T-6) DER→raw ECDSA gotcha →
cryptography decode_dss_signature + CAP-037 round-trip test; (T-7) no
AWS-managed identity (INV-15); (T-8) audit trail integrity (INV-12 +
policy_sha D-231). Includes the C-9.2 INV-18..21 compression audit:
the spec's 4 attestation concerns (immutability, signature
verifiability, key derivation, no-managed-identity) are fully captured
by INV-6 + INV-15 + INV-16 + INV-17 + REQ-332 — no semantic gap.
2026-08-19 23:22:00 +00:00
Jon Chery bcbeb7badb docs(P05): developer guide for nova auth login (REQ-346, C-7.3, lead-developer)
---ci---
project: acdl
phase: 5
milestone: v1.28
status: execute
persona: lead-developer
---
Add docs/developer-guide-auth.md covering the 5-step quickstart (signup
→ signin → login → init → apply), nova auth signup/signin/login/status/
revoke, the credentials.json file (C-7.3: OIDC token + metadata only,
NOT raw PAT, 0600), D-226 mode resolution (flag → env → credential →
TTY) with the Edge 3 TTY-vs-piped-stdout case, the JWS-from-PAT KDF
(HKDF-SHA256, HS256 symmetric, REQ-332/C-5.2), and service-account PAT
usage in CI (max TTL ≤1h, C-6.2).
2026-08-19 23:20:54 +00:00
Jon Chery 1f4f7f0f81 docs(P05): operator guide for nova idp setup (REQ-345, C-6.3, lead-developer)
---ci---
project: acdl
phase: 5
milestone: v1.28
status: execute
persona: lead-developer
---
Add docs/operator-guide-idp.md covering nova idp setup --check/--apply/
--verify, the prerequisite IAM policy delta, the CloudFormation review
flow ($PAGER + y/N), --dry-run, --public-jwks-domain, and the C-6.3
grill additions: KMS key rotation (90 days, alias re-point + overlap
window), Lambda layer update procedure, DDB PITR restore procedure
(35-day window), emergency PAT revocation (DDB-level update-item on
nova-pats, bypasses CLI, satisfies D-229 strong-read SLO).
2026-08-19 23:20:21 +00:00
Jon Chery df2b83c86b merge(phase/04): v1.28 P4 token-vend-pat complete (REQ-336..344+340/341, CAP-037/038, ABAC fail-closed) 2026-08-19 23:17:31 +00:00
5 changed files with 1654 additions and 9 deletions
+7 -9
View File
@@ -1,20 +1,18 @@
{
"phase": 4,
"phase": 5,
"stage": "complete",
"milestone": "v1.28",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-08-19T23:15:00Z",
"updated_at": "2026-08-19T23:45:00Z",
"project": "acdl",
"projects": ["acdl", "nova-blockchain-exchange"],
"active_milestone": "v1.28",
"milestone_branch": "milestone/v1.28-cli-identity",
"phase_branch": "phase/04-token-vend-pat",
"phase_branch": "phase/05-docs-integration",
"tag_line": "v1.27.x",
"phase_name": "token-vend-pat",
"reqs_covered": ["REQ-336", "REQ-337", "REQ-338", "REQ-339", "REQ-340", "REQ-341", "REQ-342", "REQ-343", "REQ-344"],
"caps_verified": ["CAP-037", "CAP-038"],
"tests": {"p4_specific": 54, "total_passing": 998, "failures": 0},
"grill_conditions_resolved": ["C-6.1/C-7.1 ABAC fail-closed", "C-5.1 requested_claims shape", "C-7.3 cred file no raw PAT", "C-8.2 kj pinned", "C-2.1 P5 folded into P4 W8"],
"notes": "v1.28 P4 SHIP. token-vend-pat complete (highest-risk, double-length, 8 waves). Tag v1.27.4. 9 REQs covered (REQ-336..344 + REQ-340/341 folded), CAP-037 + CAP-038 verified. ABAC fail-closed (7 tests), KMS ES256 DER->raw, JWKS, PAT lifecycle, nova auth, nova idp setup. 54 P4 tests + 998 total. Next: P5 docs-integration."
"phase_name": "docs-integration",
"reqs_covered": ["REQ-345", "REQ-346", "REQ-347", "REQ-348", "REQ-349", "REQ-350", "REQ-351"],
"tests": {"p5_specific": 17, "total_passing": 1000, "failures": 0},
"notes": "v1.28 P5 SHIP. docs-integration complete. Tag v1.27.5. 7 REQs covered (REQ-345..351). Operator guide (C-6.3), developer guide (C-7.3), threat model (C-6.2, C-9.2 INV audit), E2E test (REQ-348). 1000 tests passing. Next: P6 final-review-ship (milestone release)."
}
+334
View File
@@ -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 |
+385
View File
@@ -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) |
+408
View File
@@ -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.
+520
View File
@@ -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"