Compare commits

...

16 Commits

Author SHA1 Message Date
Jon Chery f68349d94d docs(ship): P4 complete → v1.27.4 (v1.28 token-vend-pat, highest-risk phase)
Nova Slides Render / render (push) Failing after 28s
---ci---
project: acdl
phase: 4
milestone: v1.28
status: complete
---/ci---
2026-08-19 23:17:31 +00:00
Jon Chery 1863a85144 feat(P04): nova idp setup --check/--apply/--verify (REQ-340/341, C-2.1, backend+cli)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: backend-engineer
---
2026-08-19 23:13:21 +00:00
Jon Chery 7dab9d5756 test(P04): CAP-037 KMS round-trip + CAP-038 PAT revocation SLO (REQ-350/351, security-engineer)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: security-engineer
---
2026-08-19 23:11:43 +00:00
Jon Chery 14809327fb feat(P04): PAT lifecycle + nova auth login/revoke/status (REQ-342..344, C-7.3, security+cli)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: cli-engineer
---
2026-08-19 23:11:16 +00:00
Jon Chery 0662ed26a3 feat(P04): nova-idp-jwks Lambda — JWKS endpoint (REQ-338, D-230, backend-engineer)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: backend-engineer
---
2026-08-19 23:08:32 +00:00
Jon Chery cd3418a75e feat(P04): nova-idp-token-vend Lambda — ABAC fail-closed + KMS sign (REQ-336, C-6.1, backend+security)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: backend-engineer
---
2026-08-19 23:08:06 +00:00
Jon Chery dee6d88d87 feat(P04): KMS ECDSA P-256 signing + DER->raw conversion (REQ-337, C-1.1, security-engineer)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: security-engineer
---
2026-08-19 23:05:25 +00:00
Jon Chery fe0ee6aa45 feat(P04): kyverno-json ABAC policy + evaluator (REQ-339, D-227, C-5.1, security-engineer)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: security-engineer
---
2026-08-19 23:04:27 +00:00
Jon Chery 701cc572ce chore(P04): kj-binary pin + platform/abac scaffold (C-8.2, D-227, backend-engineer)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: backend-engineer
---
2026-08-19 23:03:07 +00:00
Jon Chery 0736924de2 merge(phase/03): v1.28 P3 idp-auth complete (REQ-333..335, CAP-036) 2026-08-19 23:00:37 +00:00
Jon Chery 05bf8bf221 docs(ship): P3 complete → v1.27.3 (v1.28 idp-auth)
Nova Slides Render / render (push) Failing after 24s
---ci---
project: acdl
phase: 3
milestone: v1.28
status: complete
---/ci---
2026-08-19 23:00:37 +00:00
Jon Chery 7a7fbfed82 feat(P03): nova-idp-auth Lambda — sign-up/sign-in/session (REQ-333, backend-engineer) + CAP-036 E2E
Commits the full nova-idp-auth Lambda handler (sign_up/sign_in/create_session/
request_password_reset/reset_password) along with the CAP-036 E2E test
(test_idp_auth.py) covering the sign-up → sign-in → session flow, negatives
(401/409), password reset, and fail-closed 503.

---ci---
project: acdl
phase: 3
milestone: v1.28
status: execute
persona: backend-engineer
---
2026-08-19 22:58:59 +00:00
Jon Chery d06535032c test(P03): Argon2 fail-closed — ImportError → 503, no weak hash (C-1.2, security-engineer)
---ci---
project: acdl
phase: 3
milestone: v1.28
status: execute
persona: security-engineer
---
2026-08-19 22:58:57 +00:00
Jon Chery 8550ede810 feat(P03): Argon2id hashing — fail-closed, t=3 m=65536 p=1 (REQ-334, D-228, C-7.2, security-engineer)
The full nova-idp-auth Lambda handler is included in this commit (sign_up,
sign_in, create_session, request_password_reset, reset_password) since the
hashing module and handler share one file. The Argon2id hashing + fail-closed
logic is the security-engineer territory; the Lambda plumbing is backend-engineer.

---ci---
project: acdl
phase: 3
milestone: v1.28
status: execute
persona: security-engineer
---
2026-08-19 22:56:00 +00:00
Jon Chery 71562d9db2 feat(P03): DynamoDB identity schema + CFN snippet (REQ-335, backend-engineer)
---ci---
project: acdl
phase: 3
milestone: v1.28
status: execute
persona: backend-engineer
---
2026-08-19 22:55:10 +00:00
Jon Chery 91cb931bab merge(phase/02): v1.28 P2 lambda-packaging complete (REQ-329..332) 2026-08-19 22:52:36 +00:00
33 changed files with 4776 additions and 7 deletions
+9 -7
View File
@@ -1,18 +1,20 @@
{
"phase": 2,
"phase": 4,
"stage": "complete",
"milestone": "v1.28",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-08-19T22:00:00Z",
"updated_at": "2026-08-19T23:15:00Z",
"project": "acdl",
"projects": ["acdl", "nova-blockchain-exchange"],
"active_milestone": "v1.28",
"milestone_branch": "milestone/v1.28-cli-identity",
"phase_branch": "phase/02-lambda-packaging",
"phase_branch": "phase/04-token-vend-pat",
"tag_line": "v1.27.x",
"phase_name": "lambda-packaging",
"reqs_covered": ["REQ-329", "REQ-330", "REQ-331", "REQ-332"],
"tests": {"p2_specific": 43, "total_passing": 922, "failures": 0},
"notes": "v1.28 P2 SHIP. lambda-packaging complete. Tag v1.27.2. Merged phase/02 -> milestone/v1.28-cli-identity. 4 REQs covered (REQ-329..332). Dual-use refactor (>=80% share), local env synthesizer, JWS-from-PAT HKDF (C-5.2 grill fix), attestations dir. Next: P3 idp-auth."
"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."
}
+145
View File
@@ -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))
+97
View File
@@ -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)
+151
View File
@@ -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)
+613
View File
@@ -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())
+244
View File
@@ -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")
+236
View File
@@ -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))
+63
View File
@@ -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())
+175
View File
@@ -0,0 +1,175 @@
"""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 __future__ import annotations
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
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).
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}
# 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 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)
+401
View File
@@ -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())
+164
View File
@@ -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)
+51
View File
@@ -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.
+53
View File
@@ -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`).
+21
View File
@@ -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
+58
View File
@@ -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
+38
View File
@@ -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
+34
View File
@@ -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
+13
View File
@@ -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
+40
View File
@@ -0,0 +1,40 @@
"""nova idp setup --check/--apply/--verify (REQ-340, REQ-341, C-2.1, ≤50 lines)."""
from __future__ import annotations
import importlib.util
import json
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="generate + deploy (NFR-10 y/N)")
p.add_argument("--verify", action="store_true", help="run the KMS round-trip test")
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:
r = mod.verify(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
if args.apply or args.dry_run:
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
View File
+2
View File
@@ -0,0 +1,2 @@
v0.0.3
4ebb9a19fbf545e17f046c137f9b69c4288d021e5c73d962835671e0cb3fbf07
+51
View File
@@ -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
}
}
]
}
}
]
}
}
+231
View File
@@ -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"
+103
View File
@@ -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
+240
View File
@@ -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}"
)
+207
View File
@@ -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"
+444
View File
@@ -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}"
)
+191
View File
@@ -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
+123
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
"""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"
+172
View File
@@ -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)
+127
View File
@@ -0,0 +1,127 @@
"""CAP-038 PAT revocation SLO test (REQ-351).
Issue a PAT vend a token (succeeds) revoke the PAT vend a token
(403, reason ``pat_revoked``). Asserts the denial happens immediately
(D-229: the strong read on the main table is synchronous the 60s SLO
is for propagation, which with strong reads is instant; assert <1s
locally). Uses moto for DynamoDB.
"""
from __future__ import annotations
import importlib.util
import json
import os
import sys
import time
from pathlib import Path
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")
_TV_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_token_vend.py"
_spec = importlib.util.spec_from_file_location("nova_idp_token_vend_rev", _TV_PATH)
tv = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tv)
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
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}
def _create_pats_table(ddb):
ddb.create_table(
TableName="nova-pats",
KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "jti", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
@pytest.fixture(autouse=True)
def _reset():
tv._dynamodb = None
pat_life._dynamodb = None
yield
tv._dynamodb = None
pat_life._dynamodb = None
kms_signing.set_kms_client_for_testing(None)
@mock_aws
def test_pat_revocation_slo():
"""Issue → vend (ok) → revoke → vend (403 pat_revoked) in <1s (REQ-351)."""
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))
ddb = boto3.client("dynamodb", region_name="us-east-1")
_create_pats_table(ddb)
# 1. Issue a PAT.
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
# 2. Vend a token — succeeds (PAT active + ABAC allow developer+dev).
body = {"token": pat, "environment": "dev"}
resp1 = tv.lambda_handler({"body": json.dumps(body)}, None)
assert resp1["statusCode"] == 200, resp1["body"]
assert "token" in json.loads(resp1["body"])
# 3. Revoke the PAT.
import base64
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
jti = payload["jti"]
t0 = time.monotonic()
pat_life.revoke_pat(jti)
# 4. Vend again — 403 pat_revoked, immediately (<1s SLO, D-229 strong read).
resp2 = tv.lambda_handler({"body": json.dumps(body)}, None)
elapsed = time.monotonic() - t0
assert resp2["statusCode"] == 403
assert json.loads(resp2["body"])["reason"] == "pat_revoked"
assert elapsed < 1.0, f"revocation took {elapsed:.3f}s — expected <1s (D-229 strong read)"
@mock_aws
def test_pat_revocation_then_abac_still_denies():
"""After revocation, the denial reason is pat_revoked (not abac)."""
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))
ddb = boto3.client("dynamodb", region_name="us-east-1")
_create_pats_table(ddb)
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
import base64
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
pat_life.revoke_pat(payload["jti"])
body = {"token": pat, "environment": "dev"}
resp = tv.lambda_handler({"body": json.dumps(body)}, None)
assert resp["statusCode"] == 403
assert json.loads(resp["body"])["reason"] == "pat_revoked"
+195
View File
@@ -0,0 +1,195 @@
"""E2E token-vend Lambda test (REQ-336, C-6.1) with moto + mock KMS.
Valid PAT (active) + ABAC allow KMS-signed OIDC token returned.
Revoked PAT 403. Unknown PAT 403. ABAC deny 403. The returned
JWT verifies with pyjwt + the mock public key.
"""
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))
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")
_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_e2e", _SOURCE_PATH)
tv = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tv)
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
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",
)
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"):
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, env="dev", owner="t1"):
return {"body": json.dumps({
"token": pat_jwt, "environment": env,
"target_resource": {"type": "contract", "id": "c1", "owner": owner, "environment": env},
"requested_claims": ["sub", "roles"],
})}
class _MockKmsSign:
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
@pytest.fixture(autouse=True)
def _reset():
tv._dynamodb = None
yield
tv._dynamodb = None
kms_signing.set_kms_client_for_testing(None)
@mock_aws
def test_valid_pat_abac_allow_vends_token(test_keypair):
priv, pub, pub_der = test_keypair
ddb = boto3.client("dynamodb", region_name="us-east-1")
_create_pats_table(ddb)
_put_active_pat(ddb)
kms_signing.set_kms_client_for_testing(_MockKmsSign(priv, pub_der))
pat = _make_pat_jwt()
# ABAC allow: developer + dev
resp = tv.lambda_handler(_vend_event(pat, env="dev", owner="t1"), None)
assert resp["statusCode"] == 200, resp["body"]
body = json.loads(resp["body"])
assert "token" in body
assert "expires_at" in body
# Verify the JWT with pyjwt + the test public key.
pem = pub.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
decoded = pyjwt.decode(body["token"], pem, algorithms=["ES256"], options={"verify_aud": False})
assert decoded["sub"] == "user-1"
assert decoded["typ"] == "nova_oidc_token"
assert decoded["roles"] == ["developer"]
assert "jti" in decoded and "iat" in decoded and "exp" in decoded and "iss" in decoded
@mock_aws
def test_revoked_pat_denied():
ddb = boto3.client("dynamodb", region_name="us-east-1")
_create_pats_table(ddb)
_put_active_pat(ddb, jti="pat-r")
ddb.update_item(
TableName="nova-pats", Key={"jti": {"S": "pat-r"}},
UpdateExpression="SET #s = :v",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":v": {"S": "revoked"}},
)
pat = _make_pat_jwt(jti="pat-r")
resp = tv.lambda_handler(_vend_event(pat), None)
assert resp["statusCode"] == 403
assert json.loads(resp["body"])["reason"] == "pat_revoked"
@mock_aws
def test_unknown_pat_denied():
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_abac_deny_denied(test_keypair):
priv, _pub, pub_der = test_keypair
ddb = boto3.client("dynamodb", region_name="us-east-1")
_create_pats_table(ddb)
_put_active_pat(ddb)
kms_signing.set_kms_client_for_testing(_MockKmsSign(priv, pub_der))
pat = _make_pat_jwt()
# ABAC deny: developer + prod (developer not allowed in prod)
resp = tv.lambda_handler(_vend_event(pat, env="prod", owner="t1"), None)
assert resp["statusCode"] == 403
assert json.loads(resp["body"])["reason"] == "abac_denied"
@mock_aws
def test_missing_token_field():
resp = tv.lambda_handler({"body": json.dumps({"environment": "dev"})}, None)
assert resp["statusCode"] == 400