Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4697692ce7 | |||
| 23b8ff81d3 | |||
| d0a8c363b2 | |||
| 04053df16e | |||
| bcbeb7badb | |||
| 1f4f7f0f81 | |||
| df2b83c86b | |||
| f68349d94d | |||
| 1863a85144 | |||
| 7dab9d5756 | |||
| 14809327fb | |||
| 0662ed26a3 | |||
| cd3418a75e | |||
| dee6d88d87 | |||
| fe0ee6aa45 | |||
| 701cc572ce | |||
| 0736924de2 |
@@ -1,19 +1,18 @@
|
|||||||
{
|
{
|
||||||
"phase": 3,
|
"phase": 5,
|
||||||
"stage": "complete",
|
"stage": "complete",
|
||||||
"milestone": "v1.28",
|
"milestone": "v1.28",
|
||||||
"phase_role": "execution",
|
"phase_role": "execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-19T22:30:00Z",
|
"updated_at": "2026-08-19T23:45:00Z",
|
||||||
"project": "acdl",
|
"project": "acdl",
|
||||||
"projects": ["acdl", "nova-blockchain-exchange"],
|
"projects": ["acdl", "nova-blockchain-exchange"],
|
||||||
"active_milestone": "v1.28",
|
"active_milestone": "v1.28",
|
||||||
"milestone_branch": "milestone/v1.28-cli-identity",
|
"milestone_branch": "milestone/v1.28-cli-identity",
|
||||||
"phase_branch": "phase/03-idp-auth",
|
"phase_branch": "phase/05-docs-integration",
|
||||||
"tag_line": "v1.27.x",
|
"tag_line": "v1.27.x",
|
||||||
"phase_name": "idp-auth",
|
"phase_name": "docs-integration",
|
||||||
"reqs_covered": ["REQ-333", "REQ-334", "REQ-335"],
|
"reqs_covered": ["REQ-345", "REQ-346", "REQ-347", "REQ-348", "REQ-349", "REQ-350", "REQ-351"],
|
||||||
"caps_verified": ["CAP-036"],
|
"tests": {"p5_specific": 17, "total_passing": 1000, "failures": 0},
|
||||||
"tests": {"p3_specific": 22, "total_passing": 944, "failures": 0},
|
"notes": "v1.28 P5 SHIP. docs-integration complete. Tag v1.27.5. 7 REQs covered (REQ-345..351). Operator guide (C-6.3), developer guide (C-7.3), threat model (C-6.2, C-9.2 INV audit), E2E test (REQ-348). 1000 tests passing. Next: P6 final-review-ship (milestone release)."
|
||||||
"notes": "v1.28 P3 SHIP. idp-auth complete. Tag v1.27.3. Merged phase/03 -> milestone/v1.28-cli-identity. 3 REQs covered (REQ-333..335), CAP-036 verified. nova-idp-auth Lambda (sign-up/sign-in/session), Argon2id t=3 m=65536 p=1 fail-closed, 4 DDB tables. Next: P4 token-vend-pat (highest-risk, double-length)."
|
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""CloudFormation template for the Nova IdP (REQ-340, REQ-341, C-2.1).
|
||||||
|
|
||||||
|
Composes the DynamoDB snippet (from P3 ``nova_idp_auth_cfn.py``) + 3
|
||||||
|
Lambdas (``nova-idp-auth``, ``nova-idp-token-vend``, ``nova-idp-jwks``)
|
||||||
|
+ KMS key (``alias/nova-oidc-signing``, ``ECC_NIST_P256``,
|
||||||
|
``SIGN_VERIFY``) + function URLs + IAM roles + optional
|
||||||
|
CloudFront/WAF/ACM (when ``public_jwks_domain`` is provided).
|
||||||
|
|
||||||
|
:func:`generate_template` returns a CloudFormation template dict (no
|
||||||
|
troposphere dependency — raw dict → JSON).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def _load_auth_cfn():
|
||||||
|
"""Load core/lambda/nova_idp_auth_cfn.py via importlib (`lambda` is reserved)."""
|
||||||
|
p = Path(__file__).parent / "nova_idp_auth_cfn.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("nova_idp_auth_cfn", p)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
_auth_cfn = _load_auth_cfn()
|
||||||
|
dynamodb_tables_snippet = _auth_cfn.dynamodb_tables_snippet
|
||||||
|
table_names = _auth_cfn.table_names
|
||||||
|
|
||||||
|
|
||||||
|
def _lambda_role(logical_id: str, table_envs: dict[str, str], kms: bool = False) -> dict:
|
||||||
|
"""Build an IAM role for a Nova IdP Lambda."""
|
||||||
|
statements = [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
|
||||||
|
"Resource": {"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["logs:CreateLogGroup"],
|
||||||
|
"Resource": {"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if table_envs:
|
||||||
|
statements.append({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem",
|
||||||
|
"dynamodb:Query", "dynamodb:DeleteItem"],
|
||||||
|
"Resource": [
|
||||||
|
{"Fn::Sub": f"arn:aws:dynamodb:${{AWS::Region}}:${{AWS::AccountId}}:table/{name}"}
|
||||||
|
for name in table_envs.values()
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if kms:
|
||||||
|
statements.append({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],
|
||||||
|
"Resource": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"Type": "AWS::IAM::Role",
|
||||||
|
"Properties": {
|
||||||
|
"AssumeRolePolicyDocument": {
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"Service": {"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"}},
|
||||||
|
"Action": "sts:AssumeRole",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"Policies": [{"PolicyName": f"{logical_id}Policy", "PolicyDocument": {
|
||||||
|
"Version": "2012-10-17", "Statement": statements,
|
||||||
|
}}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lambda_function(logical_id: str, handler: str, role_ref: str,
|
||||||
|
env_vars: dict[str, str], memory: int = 512) -> dict:
|
||||||
|
return {
|
||||||
|
"Type": "AWS::Lambda::Function",
|
||||||
|
"Properties": {
|
||||||
|
"Handler": handler,
|
||||||
|
"Runtime": "python3.12",
|
||||||
|
"MemorySize": memory,
|
||||||
|
"Timeout": 30,
|
||||||
|
"Role": {"Fn::GetAtt": [role_ref, "Arn"]},
|
||||||
|
"Environment": {"Variables": env_vars},
|
||||||
|
"Code": {"ZipFile": "def lambda_handler(event, context):\n return {}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _function_url(logical_id: str, auth_type: str = "AWS_IAM") -> dict:
|
||||||
|
return {
|
||||||
|
"Type": "AWS::Lambda::Url",
|
||||||
|
"Properties": {
|
||||||
|
"TargetFunction": {"Ref": logical_id},
|
||||||
|
"AuthType": auth_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_template(public_jwks_domain: str | None = None) -> Dict[str, Any]:
|
||||||
|
"""Generate the full Nova IdP CloudFormation template (REQ-340).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
public_jwks_domain: optional custom domain for the JWKS endpoint.
|
||||||
|
When provided, CloudFront + ACM + WAF resources are added.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A CloudFormation template dict (``{"Resources": {...}}``).
|
||||||
|
"""
|
||||||
|
resources: Dict[str, Any] = {}
|
||||||
|
# DynamoDB tables (from P3).
|
||||||
|
resources.update(dynamodb_tables_snippet())
|
||||||
|
names = table_names()
|
||||||
|
|
||||||
|
# KMS key (ECC_NIST_P256, SIGN_VERIFY) + alias.
|
||||||
|
resources["NovaOidcSigningKey"] = {
|
||||||
|
"Type": "AWS::KMS::Key",
|
||||||
|
"Properties": {
|
||||||
|
"Description": "Nova OIDC token signing key (REQ-337, ECC_NIST_P256)",
|
||||||
|
"KeySpec": "ECC_NIST_P256",
|
||||||
|
"KeyUsage": "SIGN_VERIFY",
|
||||||
|
"KeyPolicy": {
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"AWS": {"Fn::Sub": "arn:aws:iam::${AWS::AccountId}:root"}},
|
||||||
|
"Action": "kms:*",
|
||||||
|
"Resource": "*",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resources["NovaOidcSigningKeyAlias"] = {
|
||||||
|
"Type": "AWS::KMS::Alias",
|
||||||
|
"Properties": {
|
||||||
|
"AliasName": "alias/nova-oidc-signing",
|
||||||
|
"TargetKeyId": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lambda roles.
|
||||||
|
auth_tables = {"users": names["users"], "sessions": names["sessions"],
|
||||||
|
"password_resets": names["password_resets"]}
|
||||||
|
resources["NovaIdpAuthRole"] = _lambda_role("NovaIdpAuth", auth_tables)
|
||||||
|
resources["NovaIdpTokenVendRole"] = _lambda_role(
|
||||||
|
"NovaIdpTokenVend", {"pats": names["pats"]}, kms=True)
|
||||||
|
resources["NovaIdpJwksRole"] = _lambda_role("NovaIdpJwks", {}, kms=True)
|
||||||
|
|
||||||
|
# Lambda functions.
|
||||||
|
common_env = {
|
||||||
|
"NOVA_USERS_TABLE": names["users"],
|
||||||
|
"NOVA_SESSIONS_TABLE": names["sessions"],
|
||||||
|
"NOVA_PASSWORD_RESETS_TABLE": names["password_resets"],
|
||||||
|
"NOVA_PATS_TABLE": names["pats"],
|
||||||
|
}
|
||||||
|
resources["NovaIdpAuthFunction"] = _lambda_function(
|
||||||
|
"NovaIdpAuth", "nova_idp_auth.lambda_handler", "NovaIdpAuthRole", common_env)
|
||||||
|
resources["NovaIdpTokenVendFunction"] = _lambda_function(
|
||||||
|
"NovaIdpTokenVend", "nova_idp_token_vend.lambda_handler", "NovaIdpTokenVendRole",
|
||||||
|
{**common_env, "NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"})
|
||||||
|
resources["NovaIdpJwksFunction"] = _lambda_function(
|
||||||
|
"NovaIdpJwks", "nova_idp_jwks.lambda_handler", "NovaIdpJwksRole",
|
||||||
|
{"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"}, memory=256)
|
||||||
|
|
||||||
|
# Function URLs (auth Lambda: IAM; token-vend: IAM; jwks: NONE — public).
|
||||||
|
resources["NovaIdpAuthUrl"] = _function_url("NovaIdpAuthFunction", "AWS_IAM")
|
||||||
|
resources["NovaIdpTokenVendUrl"] = _function_url("NovaIdpTokenVendFunction", "AWS_IAM")
|
||||||
|
resources["NovaIdpJwksUrl"] = _function_url("NovaIdpJwksFunction", "NONE")
|
||||||
|
|
||||||
|
# Optional: CloudFront + ACM + WAF for a custom JWKS domain.
|
||||||
|
if public_jwks_domain:
|
||||||
|
resources["NovaJwksCloudFront"] = {
|
||||||
|
"Type": "AWS::CloudFront::Distribution",
|
||||||
|
"Properties": {
|
||||||
|
"DistributionConfig": {
|
||||||
|
"Enabled": True,
|
||||||
|
"Aliases": [public_jwks_domain],
|
||||||
|
"Origins": [{
|
||||||
|
"DomainName": {"Fn::GetAtt": "NovaIdpJwksUrl.Endpoint"},
|
||||||
|
"Id": "JwksOrigin",
|
||||||
|
"CustomOriginConfig": {"OriginProtocolPolicy": "https-only"},
|
||||||
|
}],
|
||||||
|
"DefaultCacheBehavior": {
|
||||||
|
"TargetOriginId": "JwksOrigin",
|
||||||
|
"ViewerProtocolPolicy": "redirect-to-https",
|
||||||
|
"ForwardedValues": {"QueryString": False},
|
||||||
|
},
|
||||||
|
"ViewerCertificate": {
|
||||||
|
"AcmCertificateArn": {"Ref": "NovaJwksAcmCert"},
|
||||||
|
"SslSupportMethod": "sni-only",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resources["NovaJwksAcmCert"] = {
|
||||||
|
"Type": "AWS::CertificateManager::Certificate",
|
||||||
|
"Properties": {"DomainName": public_jwks_domain,
|
||||||
|
"ValidationMethod": "DNS"},
|
||||||
|
}
|
||||||
|
resources["NovaJwksWafRateRule"] = {
|
||||||
|
"Type": "AWS::WAFv2::RateBasedRule",
|
||||||
|
"Properties": {
|
||||||
|
"Name": "nova-jwks-rate-limit",
|
||||||
|
"Scope": "CLOUDFRONT",
|
||||||
|
"RateLimit": 100,
|
||||||
|
"Action": {"Block": {}},
|
||||||
|
"ComparisonOperator": "GreaterThan",
|
||||||
|
"AggregateKeyType": "IP",
|
||||||
|
"DefaultCaptchaConfig": {"ImmunityTimeProperty": {"ImmunityTime": 60}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"Resources": resources}
|
||||||
|
|
||||||
|
|
||||||
|
def resource_summary(template: dict) -> dict[str, int]:
|
||||||
|
"""Return ``{resource_type: count}`` for a template (for --dry-run)."""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for res in template.get("Resources", {}).values():
|
||||||
|
t = res.get("Type", "Unknown")
|
||||||
|
counts[t] = counts.get(t, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||||
|
import json, sys
|
||||||
|
domain = sys.argv[1] if len(sys.argv) > 1 else None
|
||||||
|
print(json.dumps(generate_template(domain), indent=2))
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Nova IdP JWKS endpoint Lambda (REQ-338, D-230).
|
||||||
|
|
||||||
|
Serves the KMS public key as a JWK in a standard JWKS response. The
|
||||||
|
endpoint is a Lambda function URL with ``AuthType: NONE`` (JWKS is
|
||||||
|
public-key only — configured in CloudFormation, not in code).
|
||||||
|
|
||||||
|
Response:
|
||||||
|
* ``Content-Type: application/json``
|
||||||
|
* ``Cache-Control: public, max-age=3600`` (1h — clients cache the JWKS)
|
||||||
|
* ``Access-Control-Allow-Origin: *`` (JWKS is public)
|
||||||
|
* ``body: {"keys": [<jwk>]}``
|
||||||
|
|
||||||
|
The JWK is built via :func:`core.kms_signing.get_jwk` from the KMS
|
||||||
|
public key (DER SPKI → ``cryptography`` → JWK).
|
||||||
|
|
||||||
|
Dual-use (REQ-329): ``__main__`` CLI block for local testing
|
||||||
|
(``--print-jwks``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
"""AWS Lambda handler — serve the JWKS response (REQ-338)."""
|
||||||
|
try:
|
||||||
|
from core.kms_signing import get_jwk
|
||||||
|
jwk = get_jwk(key_id=OIDC_KMS_KEY_ID)
|
||||||
|
return {
|
||||||
|
"statusCode": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
"body": json.dumps({"keys": [jwk]}),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"statusCode": 500,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": json.dumps({"error": str(e)}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cli_main(argv=None):
|
||||||
|
"""CLI entry point (REQ-329 dual-use). ``--print-jwks`` → stdout."""
|
||||||
|
raw = argv if argv is not None else sys.argv[1:]
|
||||||
|
if "--print-jwks" in raw:
|
||||||
|
resp = lambda_handler({}, None)
|
||||||
|
sys.stdout.write(resp["body"] + "\n")
|
||||||
|
return resp.get("statusCode", 200) - 200
|
||||||
|
print("Usage: python3 -m core.lambda.nova_idp_jwks --print-jwks", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||||
|
sys.exit(cli_main())
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""Nova IdP token-vend Lambda — PAT/session → KMS-signed OIDC token
|
||||||
|
(REQ-336, C-6.1/C-7.1 ABAC FAIL-CLOSED, D-229 revocation).
|
||||||
|
|
||||||
|
Accepts a PAT (or session token) and returns a KMS-signed OIDC token
|
||||||
|
with claims ``sub, aud, iss, exp, iat, jti, roles`` (REQ-336).
|
||||||
|
|
||||||
|
## ABAC fail-closed (C-6.1/C-7.1 — INV-17 runtime enforcement)
|
||||||
|
|
||||||
|
The grill's #1 finding: the token-vend Lambda MUST fail closed on ABAC
|
||||||
|
evaluation failure. Concretely, a token is vended **only** when:
|
||||||
|
|
||||||
|
1. The PAT is active (``nova-pats.GetItem(jti, ConsistentRead=True)``
|
||||||
|
returns an item with ``status == "active"`` — D-229; strong read on
|
||||||
|
the main table, GSIs don't support strong reads).
|
||||||
|
2. ``KyvernoJsonEngine.is_configured()`` returns ``True`` **AND**
|
||||||
|
``evaluate_token_vend_policy()`` returns ``allowed=True`` without
|
||||||
|
raising.
|
||||||
|
|
||||||
|
If (2) fails for **any** reason — ``kj`` absent, ``kj`` error, policy
|
||||||
|
parse error, engine raise — the Lambda returns **403** + audit
|
||||||
|
``token.vend.denied`` (reason ``abac_eval_failed``). **Never fail
|
||||||
|
open.** This is verified by ``tests/test_abac_fail_closed.py`` — the
|
||||||
|
most important test of the milestone.
|
||||||
|
|
||||||
|
## Dual-use (REQ-329 pattern)
|
||||||
|
|
||||||
|
Mirrors ``nova_idp_auth.py``: lazy boto3, env-var table names,
|
||||||
|
``NOVA_LAMBDA_LOCAL_BYPASS``, ``__main__`` CLI block, audit emission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config (env-var table names, mirroring nova_idp_auth.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PATS_TABLE = os.environ.get("NOVA_PATS_TABLE", "nova-pats")
|
||||||
|
SESSIONS_TABLE = os.environ.get("NOVA_SESSIONS_TABLE", "nova-sessions")
|
||||||
|
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||||
|
OIDC_ISSUER = os.environ.get("NOVA_OIDC_ISSUER", "nova-idp")
|
||||||
|
OIDC_AUDIENCE = os.environ.get("NOVA_OIDC_AUDIENCE", "nova-cli")
|
||||||
|
# OIDC token lifetime (seconds). Default 15 min.
|
||||||
|
OIDC_TTL_SECONDS = int(os.environ.get("NOVA_OIDC_TTL_SECONDS", "900"))
|
||||||
|
|
||||||
|
_dynamodb = None
|
||||||
|
_kms_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dynamodb():
|
||||||
|
"""Lazy boto3 DynamoDB resource singleton (mirrors contract_ingestor)."""
|
||||||
|
global _dynamodb
|
||||||
|
if _dynamodb is None:
|
||||||
|
_dynamodb = boto3.resource("dynamodb")
|
||||||
|
return _dynamodb
|
||||||
|
|
||||||
|
|
||||||
|
def _iso8601_now() -> str:
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch_now() -> int:
|
||||||
|
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_audit(event_type: str, **fields) -> None:
|
||||||
|
"""Emit an audit event to stderr as JSON (never the raw PAT/token)."""
|
||||||
|
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||||
|
# Defense-in-depth: scrub raw token fields (INV-16/INV-17 spirit).
|
||||||
|
for _k in ("pat", "session_token", "token", "raw_pat"):
|
||||||
|
payload.pop(_k, None)
|
||||||
|
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PAT / session decoding (decode WITHOUT verifying — signature verified
|
||||||
|
# by KMS public key separately at the JWKS verifier; the revocation
|
||||||
|
# check is the trust anchor here, not the JWT signature).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_jwt_unverified(token: str) -> dict:
|
||||||
|
"""Decode a JWT's payload without verifying the signature."""
|
||||||
|
try:
|
||||||
|
import jwt as pyjwt
|
||||||
|
return pyjwt.decode(token, options={"verify_signature": False})
|
||||||
|
except Exception:
|
||||||
|
# Fallback: manual base64url decode of the payload segment.
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise ValueError("malformed JWT (expected 3 segments)")
|
||||||
|
import base64
|
||||||
|
pad = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||||
|
return json.loads(base64.urlsafe_b64decode(pad))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pat_claims(token: str) -> dict:
|
||||||
|
"""Decode a PAT/session JWT → extract jti, sub, typ, roles, owner, exp."""
|
||||||
|
claims = _decode_jwt_unverified(token)
|
||||||
|
required = ("jti", "sub", "exp")
|
||||||
|
for f in required:
|
||||||
|
if f not in claims:
|
||||||
|
raise ValueError(f"token missing claim: {f}")
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Revocation check (D-229 — strong read on the main table)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _check_pat_active(jti: str) -> tuple[bool, str]:
|
||||||
|
"""Return ``(active, reason)``. Strong read on nova-pats main table.
|
||||||
|
|
||||||
|
D-229: GSIs don't support strongly-consistent reads, so the
|
||||||
|
revocation check uses ``GetItem(PK=jti, ConsistentRead=True)`` on
|
||||||
|
the main table. This satisfies the 60s SLO synchronously (the
|
||||||
|
strong read reflects the latest write — revocation is instant).
|
||||||
|
"""
|
||||||
|
table = _get_dynamodb().Table(PATS_TABLE)
|
||||||
|
resp = table.get_item(
|
||||||
|
TableName=PATS_TABLE,
|
||||||
|
Key={"jti": jti},
|
||||||
|
ConsistentRead=True,
|
||||||
|
)
|
||||||
|
item = resp.get("Item")
|
||||||
|
if item is None:
|
||||||
|
return False, "pat_unknown"
|
||||||
|
status = item.get("status", "active")
|
||||||
|
if status != "active":
|
||||||
|
return False, f"pat_{status}" # pat_revoked, pat_expired, etc.
|
||||||
|
# Expired? (defense-in-depth; TTL may not have reaped it yet)
|
||||||
|
expires_at = item.get("expires_at")
|
||||||
|
if expires_at is not None:
|
||||||
|
try:
|
||||||
|
if int(expires_at) < _epoch_now():
|
||||||
|
return False, "pat_expired"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return True, "active"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ABAC fail-closed (C-6.1/C-7.1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_abac_payload(claims: dict, requested_claims: list[str],
|
||||||
|
target_resource: dict, environment: str,
|
||||||
|
policy_version: str) -> dict:
|
||||||
|
"""Build the ABAC authorization payload (REQ-339, C-5.1)."""
|
||||||
|
return {
|
||||||
|
"subject": {
|
||||||
|
"id": claims.get("sub", ""),
|
||||||
|
"role": (claims.get("roles") or ["unknown"])[0],
|
||||||
|
"owner": claims.get("owner", ""),
|
||||||
|
},
|
||||||
|
"requested_claims": requested_claims,
|
||||||
|
"target_resource": target_resource,
|
||||||
|
"environment": environment,
|
||||||
|
"pat_jti": claims.get("jti", ""),
|
||||||
|
"policy_version": policy_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_abac_fail_closed(payload: dict) -> tuple[bool, list, str, str]:
|
||||||
|
"""Evaluate ABAC with fail-closed semantics (C-6.1).
|
||||||
|
|
||||||
|
Returns ``(allowed, pcrs, policy_sha, reason)``. On ANY failure
|
||||||
|
(engine not configured, evaluate raises, policy parse error) returns
|
||||||
|
``(False, [], "", "abac_eval_failed")``. **Never fails open.**
|
||||||
|
"""
|
||||||
|
# Lazy imports so the module imports without the engine adapter.
|
||||||
|
from core.policy_engine import get_engine
|
||||||
|
|
||||||
|
# C-6.1: is_configured() check. If kj is absent → fail closed.
|
||||||
|
try:
|
||||||
|
engine = get_engine()
|
||||||
|
if not engine.is_configured():
|
||||||
|
_emit_audit(
|
||||||
|
"token.vend.abac_engine_not_configured",
|
||||||
|
pat_jti=payload.get("pat_jti", ""),
|
||||||
|
)
|
||||||
|
return False, [], "", "abac_eval_failed"
|
||||||
|
except Exception: # noqa: BLE001 - fail closed on any engine check error
|
||||||
|
return False, [], "", "abac_eval_failed"
|
||||||
|
|
||||||
|
# C-6.1: evaluate() raising → fail closed.
|
||||||
|
try:
|
||||||
|
from core.abac_evaluator import evaluate_token_vend_policy
|
||||||
|
allowed, pcrs, policy_sha = evaluate_token_vend_policy(payload)
|
||||||
|
reason = "abac_denied" if not allowed else "ok"
|
||||||
|
return allowed, pcrs, policy_sha, reason
|
||||||
|
except Exception: # noqa: BLE001 - fail closed on any eval error
|
||||||
|
return False, [], "", "abac_eval_failed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token vend (REQ-336)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_oidc_claims(pat_claims: dict) -> dict:
|
||||||
|
"""Build the OIDC token claims (REQ-336)."""
|
||||||
|
now = _epoch_now()
|
||||||
|
return {
|
||||||
|
"sub": pat_claims["sub"],
|
||||||
|
"aud": OIDC_AUDIENCE,
|
||||||
|
"iss": OIDC_ISSUER,
|
||||||
|
"exp": now + OIDC_TTL_SECONDS,
|
||||||
|
"iat": now,
|
||||||
|
"jti": pat_claims.get("jti", ""), # carry the PAT jti for tracing
|
||||||
|
"roles": pat_claims.get("roles", []),
|
||||||
|
"typ": "nova_oidc_token", # INV-14: distinguish from developer_pat
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vend_token(
|
||||||
|
token: str,
|
||||||
|
requested_claims: list[str] | None = None,
|
||||||
|
target_resource: dict | None = None,
|
||||||
|
environment: str | None = None,
|
||||||
|
policy_version: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Vend a KMS-signed OIDC token for a PAT/session (REQ-336).
|
||||||
|
|
||||||
|
Returns ``{"token": ..., "expires_at": ...}`` on success. Raises
|
||||||
|
``_DeniedError`` (→ 403) on revocation / ABAC denial.
|
||||||
|
"""
|
||||||
|
requested_claims = requested_claims or ["sub", "roles"]
|
||||||
|
environment = environment or "dev"
|
||||||
|
|
||||||
|
# 1. Decode the PAT/session (without verifying — D-229).
|
||||||
|
pat_claims = _extract_pat_claims(token)
|
||||||
|
jti = pat_claims["jti"]
|
||||||
|
|
||||||
|
# Default target_resource: owner inherits from the PAT subject so
|
||||||
|
# the owner-matches ABAC rule passes for same-tenant vends. Callers
|
||||||
|
# can override with an explicit target_resource.
|
||||||
|
if target_resource is None:
|
||||||
|
target_resource = {
|
||||||
|
"type": "contract",
|
||||||
|
"id": "*",
|
||||||
|
"owner": pat_claims.get("owner", "*"),
|
||||||
|
"environment": environment,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Revocation check (D-229, strong read).
|
||||||
|
active, reason = _check_pat_active(jti)
|
||||||
|
if not active:
|
||||||
|
_emit_audit("token.vend.denied", pat_jti=jti, reason=reason)
|
||||||
|
raise _DeniedError(reason)
|
||||||
|
|
||||||
|
# 3. ABAC eval (C-6.1 FAIL-CLOSED).
|
||||||
|
abac_payload = _build_abac_payload(
|
||||||
|
pat_claims, requested_claims, target_resource, environment, policy_version
|
||||||
|
)
|
||||||
|
allowed, _pcrs, policy_sha, abac_reason = _evaluate_abac_fail_closed(abac_payload)
|
||||||
|
if not allowed:
|
||||||
|
_emit_audit(
|
||||||
|
"token.vend.denied",
|
||||||
|
pat_jti=jti,
|
||||||
|
reason=abac_reason,
|
||||||
|
policy_sha=policy_sha,
|
||||||
|
)
|
||||||
|
raise _DeniedError(abac_reason)
|
||||||
|
|
||||||
|
# 4. KMS sign (REQ-337).
|
||||||
|
from core.kms_signing import sign_jwt
|
||||||
|
oidc_claims = _build_oidc_claims(pat_claims)
|
||||||
|
oidc_token = sign_jwt(oidc_claims, key_id=OIDC_KMS_KEY_ID)
|
||||||
|
_emit_audit(
|
||||||
|
"token.vend.allowed",
|
||||||
|
pat_jti=jti,
|
||||||
|
sub=oidc_claims["sub"],
|
||||||
|
policy_sha=policy_sha,
|
||||||
|
expires_at=oidc_claims["exp"],
|
||||||
|
)
|
||||||
|
return {"token": oidc_token, "expires_at": oidc_claims["exp"]}
|
||||||
|
|
||||||
|
|
||||||
|
class _DeniedError(Exception):
|
||||||
|
"""Raised on revocation / ABAC denial → 403."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str):
|
||||||
|
self.reason = reason
|
||||||
|
super().__init__(f"token vend denied: {reason}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lambda handler + HTTP mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _to_http_response(result_or_error):
|
||||||
|
if isinstance(result_or_error, Exception):
|
||||||
|
if isinstance(result_or_error, _DeniedError):
|
||||||
|
return {
|
||||||
|
"statusCode": 403,
|
||||||
|
"body": json.dumps({"error": "token_vend_denied", "reason": result_or_error.reason}),
|
||||||
|
}
|
||||||
|
if isinstance(result_or_error, ValueError):
|
||||||
|
return {
|
||||||
|
"statusCode": 400,
|
||||||
|
"body": json.dumps({"error": str(result_or_error)}),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"statusCode": 500,
|
||||||
|
"body": json.dumps({"error": str(result_or_error)}),
|
||||||
|
}
|
||||||
|
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use)."""
|
||||||
|
try:
|
||||||
|
body = event.get("body", "{}")
|
||||||
|
payload = json.loads(body) if isinstance(body, str) else body
|
||||||
|
token = payload.get("token") or payload.get("pat") or payload.get("session_token")
|
||||||
|
if not token:
|
||||||
|
raise ValueError("missing field: token (or pat / session_token)")
|
||||||
|
result = vend_token(
|
||||||
|
token=token,
|
||||||
|
requested_claims=payload.get("requested_claims"),
|
||||||
|
target_resource=payload.get("target_resource"),
|
||||||
|
environment=payload.get("environment"),
|
||||||
|
policy_version=payload.get("policy_version", ""),
|
||||||
|
)
|
||||||
|
return _to_http_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
return _to_http_response(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI (dual-use, REQ-329 pattern)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cli_main(argv=None):
|
||||||
|
"""CLI entry point for the token-vend Lambda (REQ-329 dual-use)."""
|
||||||
|
raw = argv if argv is not None else sys.argv[1:]
|
||||||
|
local_bypass = os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
|
||||||
|
if not local_bypass:
|
||||||
|
os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
|
||||||
|
try:
|
||||||
|
if "--vend-stdin" in raw:
|
||||||
|
payload = json.loads(sys.stdin.read())
|
||||||
|
elif "--vend" in raw:
|
||||||
|
idx = raw.index("--vend")
|
||||||
|
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||||
|
if not path:
|
||||||
|
print("Usage: --vend <payload.json>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
with open(path) as fh:
|
||||||
|
payload = json.loads(fh.read())
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"Usage: python3 -m core.lambda.nova_idp_token_vend "
|
||||||
|
"--vend <payload.json> | --vend-stdin < <payload.json>",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
token = payload.get("token") or payload.get("pat") or payload.get("session_token")
|
||||||
|
if not token:
|
||||||
|
print("error: missing token in payload", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
result = vend_token(
|
||||||
|
token=token,
|
||||||
|
requested_claims=payload.get("requested_claims"),
|
||||||
|
target_resource=payload.get("target_resource"),
|
||||||
|
environment=payload.get("environment"),
|
||||||
|
policy_version=payload.get("policy_version", ""),
|
||||||
|
)
|
||||||
|
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||||
|
return 0
|
||||||
|
except _DeniedError as e:
|
||||||
|
sys.stderr.write(f"error: token vend denied ({e.reason})\n")
|
||||||
|
return 3 # 403-class
|
||||||
|
except ValueError as e:
|
||||||
|
sys.stderr.write(f"error: {e}\n")
|
||||||
|
return 1
|
||||||
|
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||||
|
sys.stderr.write(f"internal error: {e}\n")
|
||||||
|
return 2
|
||||||
|
finally:
|
||||||
|
if not local_bypass:
|
||||||
|
os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||||
|
sys.exit(cli_main())
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""PAT (personal access token) lifecycle — issue + revoke (REQ-342, REQ-343).
|
||||||
|
|
||||||
|
PATs are signed JWTs (``typ: "developer_pat"``, KMS-signed) that
|
||||||
|
authenticate a developer/service-account to the token-vend Lambda. Only
|
||||||
|
the **hash** is stored in ``nova-pats`` (REQ-343) — the raw PAT is
|
||||||
|
returned to the caller once and never persisted.
|
||||||
|
|
||||||
|
## Max TTL (C-6.2)
|
||||||
|
|
||||||
|
* developer: ≤ 24h (86400s)
|
||||||
|
* service-account: ≤ 1h (3600s)
|
||||||
|
|
||||||
|
Enforced in :func:`issue_pat` via the ``subject_type`` argument.
|
||||||
|
|
||||||
|
## DynamoDB schema (REQ-343)
|
||||||
|
|
||||||
|
* PK: ``jti`` (uuid4)
|
||||||
|
* GSI1: ``sub`` (list PATs for a user)
|
||||||
|
* GSI2: ``pat_hash`` (SHA-256 of the raw PAT for lookup)
|
||||||
|
* ``status``: ``active`` | ``revoked`` (revoked PATs retained for audit)
|
||||||
|
* ``expires_at``: epoch seconds (TTL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
PATS_TABLE = os.environ.get("NOVA_PATS_TABLE", "nova-pats")
|
||||||
|
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||||
|
OIDC_ISSUER = os.environ.get("NOVA_OIDC_ISSUER", "nova-idp")
|
||||||
|
|
||||||
|
# C-6.2 max TTLs (seconds).
|
||||||
|
MAX_TTL_DEV = 24 * 3600 # 24h
|
||||||
|
MAX_TTL_SERVICE = 3600 # 1h
|
||||||
|
|
||||||
|
_dynamodb = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dynamodb():
|
||||||
|
global _dynamodb
|
||||||
|
if _dynamodb is None:
|
||||||
|
_dynamodb = boto3.resource("dynamodb")
|
||||||
|
return _dynamodb
|
||||||
|
|
||||||
|
|
||||||
|
def _iso8601_now() -> str:
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch_now() -> int:
|
||||||
|
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_audit(event_type: str, **fields) -> None:
|
||||||
|
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||||
|
for _k in ("pat", "raw_pat"):
|
||||||
|
payload.pop(_k, None)
|
||||||
|
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _max_ttl(subject_type: str) -> int:
|
||||||
|
if subject_type == "service-account":
|
||||||
|
return MAX_TTL_SERVICE
|
||||||
|
return MAX_TTL_DEV
|
||||||
|
|
||||||
|
|
||||||
|
def issue_pat(
|
||||||
|
subject: str,
|
||||||
|
roles: list[str],
|
||||||
|
owner: str,
|
||||||
|
ttl_seconds: int,
|
||||||
|
key_id: str = OIDC_KMS_KEY_ID,
|
||||||
|
subject_type: str = "developer",
|
||||||
|
claims: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Issue a PAT (signed JWT) + store its hash in nova-pats (REQ-342).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject: the subject (user_id).
|
||||||
|
roles: the roles to embed in the PAT.
|
||||||
|
owner: the tenant owner.
|
||||||
|
ttl_seconds: requested TTL. Clamped to the C-6.2 max for
|
||||||
|
``subject_type`` (24h dev, 1h service-account).
|
||||||
|
key_id: KMS key ID/alias.
|
||||||
|
subject_type: ``"developer"`` or ``"service-account"``.
|
||||||
|
claims: extra claims to embed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The raw PAT JWT string (returned once; only the hash is stored).
|
||||||
|
"""
|
||||||
|
max_ttl = _max_ttl(subject_type)
|
||||||
|
if ttl_seconds > max_ttl:
|
||||||
|
ttl_seconds = max_ttl
|
||||||
|
if ttl_seconds < 1:
|
||||||
|
raise ValueError("ttl_seconds must be >= 1")
|
||||||
|
|
||||||
|
jti = str(uuid.uuid4())
|
||||||
|
now = _epoch_now()
|
||||||
|
exp = now + ttl_seconds
|
||||||
|
pat_claims = {
|
||||||
|
"iss": OIDC_ISSUER,
|
||||||
|
"sub": subject,
|
||||||
|
"typ": "developer_pat",
|
||||||
|
"jti": jti,
|
||||||
|
"iat": now,
|
||||||
|
"exp": exp,
|
||||||
|
"roles": roles,
|
||||||
|
"owner": owner,
|
||||||
|
}
|
||||||
|
if claims:
|
||||||
|
pat_claims.update(claims)
|
||||||
|
|
||||||
|
from core.kms_signing import sign_jwt
|
||||||
|
pat_jwt = sign_jwt(pat_claims, key_id=key_id)
|
||||||
|
|
||||||
|
# Only the hash is stored (REQ-343) — NOT the raw PAT.
|
||||||
|
pat_hash = hashlib.sha256(pat_jwt.encode("ascii")).hexdigest()
|
||||||
|
table = _get_dynamodb().Table(PATS_TABLE)
|
||||||
|
table.put_item(
|
||||||
|
TableName=PATS_TABLE,
|
||||||
|
Item={
|
||||||
|
"jti": jti,
|
||||||
|
"sub": subject,
|
||||||
|
"pat_hash": pat_hash,
|
||||||
|
"status": "active",
|
||||||
|
"issued_at": _iso8601_now(),
|
||||||
|
"expires_at": str(exp),
|
||||||
|
"subject_type": subject_type,
|
||||||
|
"claims": json.dumps(pat_claims),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_emit_audit("pat.issued", jti=jti, sub=subject, subject_type=subject_type, ttl=ttl_seconds)
|
||||||
|
return pat_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_pat(jti: str) -> dict:
|
||||||
|
"""Revoke a PAT (D-229, REQ-342). Revoked PATs retained for audit.
|
||||||
|
|
||||||
|
Returns the update response. Audit ``pat.revoked`` emitted.
|
||||||
|
"""
|
||||||
|
table = _get_dynamodb().Table(PATS_TABLE)
|
||||||
|
resp = table.update_item(
|
||||||
|
TableName=PATS_TABLE,
|
||||||
|
Key={"jti": jti},
|
||||||
|
UpdateExpression="SET #s = :rev, revoked_at = :now",
|
||||||
|
ExpressionAttributeNames={"#s": "status"},
|
||||||
|
ExpressionAttributeValues={":rev": "revoked", ":now": _iso8601_now()},
|
||||||
|
)
|
||||||
|
_emit_audit("pat.revoked", jti=jti)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||||
|
print("use nova/auth/login.py and nova/auth/revoke.py", file=sys.stderr)
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Developer Guide — Nova Auth (`nova auth`)
|
||||||
|
|
||||||
|
> **REQ-346** — developer guide for `nova auth login`. Covers signup,
|
||||||
|
> signin, login, mode resolution, TTY vs piped stdout behavior, and the
|
||||||
|
> JWS-from-PAT KDF (REQ-332, C-5.2).
|
||||||
|
>
|
||||||
|
> Audience: developers using the Nova CLI to authenticate and run
|
||||||
|
> `nova apply`. For operator-side identity stack deployment, see
|
||||||
|
> `docs/operator-guide-idp.md`.
|
||||||
|
|
||||||
|
## 1. Quickstart (5 steps)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Sign up (one-time per user).
|
||||||
|
nova auth signup --email alice@example.com --owner team-a
|
||||||
|
|
||||||
|
# 2. Sign in (returns a session — valid 24h).
|
||||||
|
nova auth signin --email alice@example.com
|
||||||
|
|
||||||
|
# 3. Issue a PAT and log in (session → OIDC token, stored locally).
|
||||||
|
nova auth login --pat <PAT>
|
||||||
|
|
||||||
|
# 4. Initialize a project (one-time per repo).
|
||||||
|
nova init
|
||||||
|
|
||||||
|
# 5. Apply locally + sign a local-review attestation.
|
||||||
|
nova apply --local --sign-local-review --contract .nova/contract.yml --pat <PAT>
|
||||||
|
```
|
||||||
|
|
||||||
|
After step 3, `~/.nova/credentials.json` holds your active OIDC token
|
||||||
|
(see §4). After step 5, the attestation is a JWS verifiable with the
|
||||||
|
PAT-derived key (see §7).
|
||||||
|
|
||||||
|
## 2. `nova auth signup`
|
||||||
|
|
||||||
|
Creates a user in the `nova-users` DynamoDB table. The password is
|
||||||
|
hashed with **Argon2id** (OWASP-minimum parameters: `time_cost=3,
|
||||||
|
memory_cost=65536 KiB, parallelism=1`) — the raw password is **never**
|
||||||
|
stored, logged, or put in any env var (INV-16).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth signup --email alice@example.com --password '...' --owner team-a
|
||||||
|
```
|
||||||
|
|
||||||
|
What happens server-side (the `nova-idp-auth` Lambda):
|
||||||
|
1. Validates the payload (`email`, `password`, `owner`, `roles`).
|
||||||
|
2. Checks for a duplicate email → `409` if already registered.
|
||||||
|
3. `hash_password(password)` → Argon2id hash string.
|
||||||
|
4. `PutItem` into `nova-users` (`user_id`, `email`, `password_hash`,
|
||||||
|
`owner`, `roles`, `created_at`).
|
||||||
|
5. Emits `auth.sign_up` audit event (carries `user_id` + `email`,
|
||||||
|
never the password).
|
||||||
|
|
||||||
|
If the Argon2 C extension is unavailable, the Lambda returns **503**
|
||||||
|
(fail-closed — no weak hash, no pure-Python fallback; D-228).
|
||||||
|
|
||||||
|
## 3. `nova auth signin`
|
||||||
|
|
||||||
|
Verifies the password and returns a session token.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth signin --email alice@example.com --password '...'
|
||||||
|
```
|
||||||
|
|
||||||
|
The Lambda:
|
||||||
|
1. Looks up the user by email (GSI `email-index` on `nova-users`).
|
||||||
|
2. `verify_password(password, stored_hash)` — Argon2id verify.
|
||||||
|
3. On mismatch or unknown email → `401 invalid_credentials` (the same
|
||||||
|
message for both, so an attacker can't enumerate emails by timing).
|
||||||
|
4. On success: `create_session(user_id)` writes a row to `nova-sessions`
|
||||||
|
(TTL 24h) and returns `session_id`.
|
||||||
|
|
||||||
|
## 4. `nova auth login`
|
||||||
|
|
||||||
|
Exchanges a PAT (or session) for a Nova OIDC token and stores it
|
||||||
|
locally.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth login --pat <PAT>
|
||||||
|
# or
|
||||||
|
nova auth login --session <session_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The flow:
|
||||||
|
1. The CLI calls the `nova-idp-token-vend` Lambda with the PAT.
|
||||||
|
2. The Lambda decodes the PAT's `jti`, does a **strongly-consistent**
|
||||||
|
`GetItem` on `nova-pats` (D-229 — revocation is reflected on the
|
||||||
|
next vend, within 60s P95).
|
||||||
|
3. Evaluates the ABAC policy (`platform/abac/token-vend.policy`) —
|
||||||
|
fail-closed (C-6.1). If the policy engine is unavailable or the
|
||||||
|
policy denies, the vend returns `403`.
|
||||||
|
4. Signs the OIDC token via KMS (`alias/nova-oidc-signing`,
|
||||||
|
`ECC_NIST_P256`, `ECDSA_SHA_256`) and returns it.
|
||||||
|
|
||||||
|
### The credentials file (`~/.nova/credentials.json`)
|
||||||
|
|
||||||
|
**C-7.3 (grill):** the file stores the OIDC token + PAT metadata
|
||||||
|
(`jti`, `exp`, `type`) **ONLY — NOT the raw PAT.** The raw PAT is
|
||||||
|
entered once at `nova auth login` and never persisted. This reduces the
|
||||||
|
filesystem-compromise blast radius: an attacker who reads
|
||||||
|
`credentials.json` gets a short-lived OIDC token (default 15 min), not
|
||||||
|
the long-lived PAT.
|
||||||
|
|
||||||
|
The file is `0600` (owner read/write only). Shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active_credential_jti": "<jti>",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"jti": "<jti>",
|
||||||
|
"type": "nova_oidc_token",
|
||||||
|
"exp": 1787200000,
|
||||||
|
"token": "<oidc jwt>",
|
||||||
|
"stored_at": 1787199000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
"Most recent wins": `active_credential_jti` points at the
|
||||||
|
most-recently-stored credential. A subsequent `nova auth login`
|
||||||
|
replaces the entry with the same `jti` (or adds a new one).
|
||||||
|
|
||||||
|
## 5. `nova auth status`
|
||||||
|
|
||||||
|
Shows the active credential, the resolved mode, and the
|
||||||
|
`selection_reason`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth status
|
||||||
|
```
|
||||||
|
|
||||||
|
Output (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "interactive",
|
||||||
|
"selection_reason": "credential:developer_pat",
|
||||||
|
"type": "nova_oidc_token",
|
||||||
|
"jti": "...",
|
||||||
|
"exp": 1787200000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If no credential is stored: `{"status": "no active credential"}`.
|
||||||
|
|
||||||
|
## 6. `nova auth revoke --pat <jti>`
|
||||||
|
|
||||||
|
Revokes a PAT by `jti`. Marks the `nova-pats` row `status=revoked`
|
||||||
|
(the row is **retained** for audit, not deleted). The next
|
||||||
|
`nova auth login` with that PAT returns `403 pat_revoked` within 60s
|
||||||
|
P95 (D-229 strong read).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova auth revoke --pat <jti>
|
||||||
|
```
|
||||||
|
|
||||||
|
For emergency DDB-level revocation (when the CLI is unavailable), see
|
||||||
|
`docs/operator-guide-idp.md` §9.
|
||||||
|
|
||||||
|
## 7. Mode resolution (D-226)
|
||||||
|
|
||||||
|
The CLI resolves a client mode (`interactive` or `agent`) on every
|
||||||
|
invocation. The mode drives audit observability (INV-12) and some
|
||||||
|
behavioral defaults. The priority is **strict** — no silent fallbacks
|
||||||
|
(INV-13):
|
||||||
|
|
||||||
|
1. **`--mode` flag** (always wins): `nova apply --mode=agent`.
|
||||||
|
2. **`NOVA_CLIENT_MODE` env var**: `export NOVA_CLIENT_MODE=agent`.
|
||||||
|
Invalid values (anything other than `agent` / `interactive`) are
|
||||||
|
**warned and ignored** (fall through to the next level — not a
|
||||||
|
silent fallback, because a warning is emitted).
|
||||||
|
3. **Credential type** (from `~/.nova/credentials.json`): if the active
|
||||||
|
credential is `developer_pat` or `nova_oidc_token`, the mode is
|
||||||
|
`interactive` if a TTY is attached, `agent` otherwise (INV-14).
|
||||||
|
4. **TTY heuristic** (`sys.stdin.isatty()`): `interactive` if stdin is
|
||||||
|
a TTY, `agent` otherwise.
|
||||||
|
|
||||||
|
Every resolution returns a non-empty `selection_reason` (`flag`, `env`,
|
||||||
|
`credential:<type>`, or `tty`) so the audit event is self-explanatory.
|
||||||
|
|
||||||
|
### TTY vs piped stdout — the Edge 3 case
|
||||||
|
|
||||||
|
The TTY check is **`sys.stdin.isatty()`**, not `sys.stdout.isatty()`.
|
||||||
|
This matters when stdout is piped but stdin is still a terminal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova apply | tee log.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Here `stdout` is a pipe (to `tee`), but `stdin` is still the terminal.
|
||||||
|
So `sys.stdin.isatty()` returns `True` → **interactive mode**. This is
|
||||||
|
the common "I want to see the output AND save it" pattern, and it
|
||||||
|
correctly resolves to interactive because the human is driving.
|
||||||
|
|
||||||
|
The inverse — `echo '...' | nova apply` — has `stdin` piped, so
|
||||||
|
`sys.stdin.isatty()` is `False` → **agent mode** (no human at the
|
||||||
|
keyboard; the pipe is the driver).
|
||||||
|
|
||||||
|
### `developer_pat` + TTY → interactive; + no TTY → agent
|
||||||
|
|
||||||
|
A developer PAT (`type: developer_pat`) is a human credential. When a
|
||||||
|
TTY is attached, the CLI runs in `interactive` mode (prompts, human
|
||||||
|
confirmation). When no TTY is attached (piped stdin, CI, a scheduled
|
||||||
|
job), the same PAT runs in `agent` mode (no prompts, non-interactive).
|
||||||
|
This is INV-14: the credential type encodes the role, and the TTY
|
||||||
|
encodes the context.
|
||||||
|
|
||||||
|
A service-account PAT behaves the same way by type, but the max TTL is
|
||||||
|
much shorter (≤ 1h vs ≤ 24h for developer PATs — C-6.2) and CI systems
|
||||||
|
typically set `NOVA_CLIENT_MODE=agent` explicitly so the resolution is
|
||||||
|
deterministic regardless of the TTY state.
|
||||||
|
|
||||||
|
## 8. JWS-from-PAT key derivation (REQ-332, C-5.2)
|
||||||
|
|
||||||
|
`nova apply --local --sign-local-review` produces a JWS attestation — a
|
||||||
|
symmetric (HMAC-SHA256) signature over the attestation payload, keyed
|
||||||
|
by a key derived from the PAT.
|
||||||
|
|
||||||
|
### Why symmetric?
|
||||||
|
|
||||||
|
The grill (C-5.2) found that the original REQ-332 acceptance criterion
|
||||||
|
("public key derivable from the PAT") is unimplementable as an
|
||||||
|
asymmetric scheme — a PAT is a JWT, not a keypair. The fix: the PAT is
|
||||||
|
the **shared secret**. Both the signing key and the verification key
|
||||||
|
are derived from the PAT via the same KDF. The JWS uses `HS256`
|
||||||
|
(HMAC-SHA256), not `ES256`.
|
||||||
|
|
||||||
|
### The KDF
|
||||||
|
|
||||||
|
```
|
||||||
|
key = HKDF-SHA256(
|
||||||
|
input_key_material = PAT.encode('utf-8'),
|
||||||
|
salt = b'nova-local-attestation',
|
||||||
|
info = b'jws-signing-key',
|
||||||
|
length = 32,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
(RFC 5869 / NIST SP 800-56C.) The `salt` and `info` are fixed
|
||||||
|
constants — they bind the derived key to the "nova-local-attestation /
|
||||||
|
jws-signing-key" purpose (key separation, INV-16). The same PAT always
|
||||||
|
yields the same key (deterministic); the key is never cached or
|
||||||
|
persisted (INV-15 — recomputed on each sign/verify call).
|
||||||
|
|
||||||
|
### Signing (`nova apply --local --sign-local-review`)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova apply --local --sign-local-review --pat <PAT> --contract .nova/contract.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `core.jws_attestation.sign_attestation(payload, pat)`:
|
||||||
|
- `derive_signing_key(pat)` → 32-byte key.
|
||||||
|
- `header = {"alg":"HS256","typ":"JWT"}`.
|
||||||
|
- `signing_input = b64url(header) + "." + b64url(payload)`.
|
||||||
|
- `signature = HMAC-SHA256(key, signing_input)`.
|
||||||
|
- Returns `b64url(header).b64url(payload).b64url(signature)` (the
|
||||||
|
compact JWS serialization).
|
||||||
|
2. The JWS is appended to the apply output.
|
||||||
|
|
||||||
|
### Verifying
|
||||||
|
|
||||||
|
Anyone holding the PAT can derive the same key and verify:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.jws_attestation import verify_attestation
|
||||||
|
payload = verify_attestation(jws_string, pat)
|
||||||
|
# raises JWSValidationError on tampering or wrong PAT
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify_attestation` recomputes the HMAC and compares in constant time
|
||||||
|
(`hmac.compare_digest`). Without the PAT, the HMAC cannot be forged —
|
||||||
|
this is the integrity guarantee for local-review attestations.
|
||||||
|
|
||||||
|
### What this is NOT
|
||||||
|
|
||||||
|
- **Not a non-repudiation scheme.** Anyone with the PAT can sign, so
|
||||||
|
the signature proves "someone with the PAT signed this payload" —
|
||||||
|
not a specific individual. Non-repudiation is the job of the audit
|
||||||
|
trail (INV-12), not the JWS.
|
||||||
|
- **Not a replacement for the OIDC token.** The OIDC token (from
|
||||||
|
`nova auth login`) is the credential for remote operations; the JWS
|
||||||
|
is for local-review attestation integrity only.
|
||||||
|
|
||||||
|
## 9. Service-account PATs (CI usage)
|
||||||
|
|
||||||
|
A CI system (GitHub Actions, or an internal forge runner) uses a service-account
|
||||||
|
PAT to run `nova apply` non-interactively.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# In CI:
|
||||||
|
export NOVA_PAT=<service-account-pat>
|
||||||
|
export NOVA_CLIENT_MODE=agent
|
||||||
|
nova auth login --pat "$NOVA_PAT"
|
||||||
|
nova apply --contract contracts/microservice.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
- `NOVA_CLIENT_MODE=agent` makes mode resolution deterministic (level 2
|
||||||
|
beats level 3/4), regardless of whether the CI runner attaches a TTY.
|
||||||
|
- No TTY → `agent` mode anyway, but the env var is belt-and-suspenders.
|
||||||
|
- **Max TTL: ≤ 1h for service-account PATs** (C-6.2). The
|
||||||
|
`issue_pat(subject_type="service-account", ttl_seconds=3600)` call
|
||||||
|
clamps any higher request to 3600s. Rotate the PAT before it expires
|
||||||
|
(CI should mint a fresh one per run or daily).
|
||||||
|
|
||||||
|
### TTL summary (C-6.2)
|
||||||
|
|
||||||
|
| Subject type | Max TTL | Typical use |
|
||||||
|
|--------------|---------|-------------|
|
||||||
|
| `developer` | ≤ 24h (86400s) | local dev, interactive |
|
||||||
|
| `service-account` | ≤ 1h (3600s) | CI, automated pipelines |
|
||||||
|
|
||||||
|
The TTL is enforced in `core.pat_lifecycle.issue_pat` — a request for
|
||||||
|
more than the max is silently clamped (with an audit event recording
|
||||||
|
the requested vs actual TTL).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix — command reference
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|--------------|
|
||||||
|
| `nova auth signup` | create a user (Argon2id hash) |
|
||||||
|
| `nova auth signin` | verify password → session token |
|
||||||
|
| `nova auth login --pat <PAT>` | PAT → OIDC token, store in `~/.nova/credentials.json` (0600) |
|
||||||
|
| `nova auth status` | active credential + mode + selection_reason |
|
||||||
|
| `nova auth revoke --pat <jti>` | mark a PAT revoked (D-229 SLO ≤ 60s P95) |
|
||||||
|
| `nova apply --local --sign-local-review --pat <PAT>` | local apply + JWS attestation (HS256, PAT-derived key) |
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `~/.nova/credentials.json` | OIDC token + PAT metadata (NOT raw PAT); 0600 |
|
||||||
|
| `~/.nova/contract.yml` | project contract (scaffolded by `nova init`) |
|
||||||
|
| `~/.nova/contract.yml.attestations/` | local attestation outputs |
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# kyverno-json (`kj`) Lambda layer
|
||||||
|
|
||||||
|
This document records how the `kj` (kyverno-json) binary is pinned and
|
||||||
|
bundled into the Nova token-vend Lambda layer (D-227, C-8.2).
|
||||||
|
|
||||||
|
## Pin (C-8.2)
|
||||||
|
|
||||||
|
The `kj` binary is pinned to a specific release. The version + SHA256
|
||||||
|
of the binary used for local ABAC tests and bundled into the Lambda
|
||||||
|
layer are recorded in [`platform/abac/kj-version.txt`](../platform/abac/kj-version.txt):
|
||||||
|
|
||||||
|
```
|
||||||
|
<version>
|
||||||
|
<sha256>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current pin:** `v0.0.3` —
|
||||||
|
`4ebb9a19fbf545e17f046c137f9b69c4288d021e5c73d962835671e0cb3fbf07`
|
||||||
|
(measured from `/usr/local/bin/kj` on the build host).
|
||||||
|
|
||||||
|
C-8.2 requires pinning to a specific release (not `latest`) and
|
||||||
|
recording the SHA256 so a supply-chain compromise of the upstream
|
||||||
|
release is detectable. The build step downloads the pinned release,
|
||||||
|
verifies the SHA256 against the recorded value, and aborts on mismatch.
|
||||||
|
|
||||||
|
## Lambda layer bundling
|
||||||
|
|
||||||
|
The publish workflow (P1, `.github/workflows/`) bundles the pinned `kj`
|
||||||
|
Linux amd64 binary into the `nova-cli` Lambda layer at `layer/bin/kj`.
|
||||||
|
At runtime the Lambda mounts the layer at `/opt`, so `kj` is on PATH at
|
||||||
|
`/opt/bin/kj`. `KyvernoJsonEngine.is_configured()` checks `which kj` →
|
||||||
|
`/opt/bin/kj` and returns `False` when absent — the token-vend Lambda
|
||||||
|
then **fails closed** (C-6.1, 403 `abac_eval_failed`), it never vends a
|
||||||
|
token without an ABAC decision.
|
||||||
|
|
||||||
|
## Local testing
|
||||||
|
|
||||||
|
`/usr/local/bin/kj` exists on the build host. The local ABAC tests
|
||||||
|
(`tests/test_abac_policy.py`, `tests/test_abac_fail_closed.py`) use the
|
||||||
|
real `kj` binary — they are skipped (not failed) when `kj` is absent.
|
||||||
|
|
||||||
|
## Fallback / migration path (D-227)
|
||||||
|
|
||||||
|
If the `kj` Go binary proves unsuitable for the Lambda runtime (e.g. a
|
||||||
|
future release exceeds the 250 MB layer unzip limit or drops AL2023
|
||||||
|
compatibility), the migration path is to run kyverno-json on AWS
|
||||||
|
Fargate behind an internal NLB and have the token-vend Lambda call it
|
||||||
|
over HTTP. The `PolicyEngine` Protocol (`core/policy_engine.py`) is the
|
||||||
|
swap boundary — a `KyvernoJsonHttpEngine` would implement the same
|
||||||
|
protocol without touching the token-vend Lambda's ABAC fail-closed
|
||||||
|
logic. This is a documented fallback, not the v1.28 default.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# KMS asymmetric key provisioning (C-1.1)
|
||||||
|
|
||||||
|
This document records the C-1.1 verification for the Nova OIDC signing
|
||||||
|
KMS key and the provisioning path used by `nova idp setup`.
|
||||||
|
|
||||||
|
## C-1.1 verification (P4)
|
||||||
|
|
||||||
|
C-1.1 requires verifying KMS asymmetric key support **before**
|
||||||
|
implementation. The verification command is:
|
||||||
|
|
||||||
|
```
|
||||||
|
aws kms create-key \
|
||||||
|
--key-spec ECC_NIST_P256 \
|
||||||
|
--key-usage SIGN_VERIFY \
|
||||||
|
--description nova-oidc-signing
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result on the P4 build host:** AWS credentials are not available
|
||||||
|
(`Unable to locate credentials`), so the live verification could not
|
||||||
|
run. This is recorded as a **P4 CI gate**: the `nova idp setup --check`
|
||||||
|
command (Wave 8) performs this verification when AWS creds are present
|
||||||
|
and reports it as a missing prerequisite when they are not. The code
|
||||||
|
proceeds against the documented KMS API (REQ-337); tests use a test
|
||||||
|
ECDSA P-256 keypair + mocked `boto3.client("kms")` (no real AWS calls).
|
||||||
|
|
||||||
|
KMS asymmetric signing keys (`ECC_NIST_P256` + `SIGN_VERIFY`) are GA
|
||||||
|
in all commercial regions (announced 2020-11). The
|
||||||
|
`ECDSA_SHA_256` signing algorithm is supported. Confidence: high.
|
||||||
|
|
||||||
|
## Key spec (REQ-337)
|
||||||
|
|
||||||
|
* **Key spec:** `ECC_NIST_P256` (NIST P-256 / secp256r1)
|
||||||
|
* **Key usage:** `SIGN_VERIFY`
|
||||||
|
* **Signing algorithm:** `ECDSA_SHA_256` (JWS `ES256`)
|
||||||
|
* **Alias:** `alias/nova-oidc-signing`
|
||||||
|
* **Rotation:** manual, 90 days (matches D-069 CMK cadence). New key +
|
||||||
|
re-point alias + JWKS serves both `kid`s during overlap.
|
||||||
|
|
||||||
|
## DER → raw ECDSA conversion (the #1 gotcha)
|
||||||
|
|
||||||
|
KMS `sign()` returns a **DER-encoded** ASN.1 ECDSA signature. JWS
|
||||||
|
(RFC 7515 §3.1.3) requires the **raw** `r‖s` concatenation, each
|
||||||
|
coordinate 32 bytes big-endian. The conversion (in
|
||||||
|
`core/kms_signing.py:der_to_raw_ecdsa`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
||||||
|
r, s = decode_dss_signature(der_sig)
|
||||||
|
raw = r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
||||||
|
```
|
||||||
|
|
||||||
|
This is verified by `tests/test_kms_signing.py` and the CAP-037
|
||||||
|
round-trip test (`tests/test_kms_roundtrip.py`).
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
# Operator Guide — Nova IdP Setup (`nova idp setup`)
|
||||||
|
|
||||||
|
> **REQ-345** — operator guide for `nova idp setup`. Covers `--check`,
|
||||||
|
> `--apply`, `--verify`, the prerequisite IAM policy, the CloudFormation
|
||||||
|
> review flow, and the **C-6.3 grill additions**: KMS key rotation
|
||||||
|
> (90 days), Lambda layer update, DDB PITR restore, emergency PAT
|
||||||
|
> revocation (DDB-level, not CLI).
|
||||||
|
>
|
||||||
|
> Audience: platform operators / SREs deploying the Nova identity stack
|
||||||
|
> into AWS account `581513795199` (or a fresh account). No developer
|
||||||
|
> auth flows here — see `docs/developer-guide-auth.md` for those.
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
`nova idp setup` provisions the Nova identity layer (Nova-idp) as a
|
||||||
|
CloudFormation stack. The stack contains:
|
||||||
|
|
||||||
|
| Resource | Count | Notes |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| Lambda functions | 3 | `nova-idp-auth`, `nova-idp-token-vend`, `nova-idp-jwks` |
|
||||||
|
| DynamoDB tables | 4 | `nova-users`, `nova-sessions`, `nova-password-resets`, `nova-pats` (PITR enabled on each, REQ-335) |
|
||||||
|
| KMS asymmetric key | 1 | `alias/nova-oidc-signing` (`ECC_NIST_P256`, `SIGN_VERIFY`) |
|
||||||
|
| Lambda function URLs | 3 | auth + token-vend (IAM auth), jwks (`AuthType: NONE`) |
|
||||||
|
| IAM roles | 3+ | one per Lambda + the CloudFormation service role |
|
||||||
|
| Optional CloudFront + WAF + ACM | 0/3 | only with `--public-jwks-domain` |
|
||||||
|
|
||||||
|
The command has three modes — `--check`, `--apply`, `--verify` — plus
|
||||||
|
`--dry-run` for a resource-only preview. All modes are safe to re-run.
|
||||||
|
|
||||||
|
## 2. `nova idp setup --check`
|
||||||
|
|
||||||
|
Run **before** `--apply` to verify the deploying principal has the
|
||||||
|
permissions and environment the stack needs.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova idp setup --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### What it checks
|
||||||
|
|
||||||
|
1. **AWS credentials** — `aws sts get-caller-identity` succeeds and
|
||||||
|
returns an `Account` id. If this fails, run `aws configure` or export
|
||||||
|
`AWS_PROFILE` / `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`.
|
||||||
|
2. **AWS region** — `AWS_DEFAULT_REGION` or `AWS_REGION` is set. The
|
||||||
|
stack is regional (single-region); pick the region you want all
|
||||||
|
resources to live in.
|
||||||
|
3. **CloudFormation permissions** — the principal can create/describe
|
||||||
|
stacks (see §5 for the full IAM delta).
|
||||||
|
4. **KMS permissions** — `kms:CreateKey` + `kms:CreateAlias` (needed to
|
||||||
|
mint `alias/nova-oidc-signing`).
|
||||||
|
5. **Lambda layer exists** — the `nova-cli` Lambda layer (published by
|
||||||
|
the P1 Wave 4 pipeline) is referenced by the stack; `--check` reports
|
||||||
|
whether the layer ARN in SSM (`/nova/layer/nova-cli/version`) is
|
||||||
|
present. If absent, run the publish workflow or `nova layer update`.
|
||||||
|
|
||||||
|
### Reading the IAM policy delta
|
||||||
|
|
||||||
|
`--check` prints a report like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aws_creds": true,
|
||||||
|
"region": "us-east-1",
|
||||||
|
"missing": [],
|
||||||
|
"iam_delta": [
|
||||||
|
"cloudformation:*",
|
||||||
|
"iam:CreateRole",
|
||||||
|
"iam:PassRole",
|
||||||
|
"lambda:CreateFunction",
|
||||||
|
"lambda:CreateFunctionUrlConfig",
|
||||||
|
"dynamodb:CreateTable",
|
||||||
|
"kms:CreateKey",
|
||||||
|
"kms:CreateAlias"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`iam_delta` is the **delta** between what the deploying principal
|
||||||
|
currently has (the `nova-spike-runner` grants in this account) and what
|
||||||
|
`--apply` needs. Each entry is a grant you must add to the principal's
|
||||||
|
policy before `--apply` will succeed. `--check` never makes changes.
|
||||||
|
|
||||||
|
## 3. `nova idp setup --apply`
|
||||||
|
|
||||||
|
Generates the CloudFormation template, presents it for review, and
|
||||||
|
deploys **only after explicit `y/N` approval** (NFR-10).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova idp setup --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
### Review flow
|
||||||
|
|
||||||
|
1. **Resource summary** printed to stdout (resource type → count):
|
||||||
|
```
|
||||||
|
Resource summary:
|
||||||
|
AWS::DynamoDB::Table: 4
|
||||||
|
AWS::IAM::Role: 3
|
||||||
|
AWS::KMS::Key: 1
|
||||||
|
AWS::Lambda::Function: 3
|
||||||
|
AWS::Lambda::Url: 3
|
||||||
|
```
|
||||||
|
2. **Full template** opened in `$PAGER` (if set and stdin is a TTY);
|
||||||
|
otherwise the path to the temp file is printed. Review every
|
||||||
|
resource, especially the KMS key policy and the IAM roles.
|
||||||
|
3. **`Apply? [y/N]` prompt.** Type `y` + Enter to deploy; anything else
|
||||||
|
aborts. No resource is created before this approval.
|
||||||
|
4. On approval: `aws cloudformation deploy --stack-name nova-idp
|
||||||
|
--template-file <tmp> --capabilities CAPABILITY_IAM`.
|
||||||
|
|
||||||
|
### `--dry-run` — resource list only
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova idp setup --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Generates the template and prints the resource summary **without** the
|
||||||
|
pager, the prompt, or any deploy. Use this to audit the stack shape in
|
||||||
|
CI or before a manual `--apply`.
|
||||||
|
|
||||||
|
### `--public-jwks-domain` — optional custom domain + WAF
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova idp setup --apply --public-jwks-domain jwks.nova.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Adds a CloudFront distribution fronting the JWKS Lambda function URL, an
|
||||||
|
ACM certificate (DNS-validated) for the domain, and a WAF web ACL with
|
||||||
|
a rate-based rule (see §C-6.3 and the threat model). Without this flag
|
||||||
|
the JWKS endpoint is a bare function URL (`AuthType: NONE`) — fine for
|
||||||
|
piloting but exposed to the internet without rate limiting. **For any
|
||||||
|
public deployment, set `--public-jwks-domain`.**
|
||||||
|
|
||||||
|
## 4. `nova idp setup --verify`
|
||||||
|
|
||||||
|
Runs the KMS round-trip test (CAP-037) against the deployed stack.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova idp setup --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
It signs a test JWT via `core.kms_signing.sign_jwt()` (using the real
|
||||||
|
KMS key `alias/nova-oidc-signing`), fetches the JWKS endpoint, and
|
||||||
|
verifies the JWT signature with `pyjwt` + the JWKS key. This exercises
|
||||||
|
the full DER → raw ECDSA conversion path (the #1 implementation risk —
|
||||||
|
see `docs/threat-model.md`).
|
||||||
|
|
||||||
|
**Success output:**
|
||||||
|
```json
|
||||||
|
{"passed": true, "detail": "KMS round-trip OK"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Failure output:**
|
||||||
|
```json
|
||||||
|
{"passed": false, "detail": "verify error: <exception>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common failure causes:
|
||||||
|
- The KMS key policy doesn't grant `kms:Sign` to the verify caller.
|
||||||
|
- The JWKS function URL is not deployed or returns a non-200.
|
||||||
|
- The KMS key spec isn't `ECC_NIST_P256` (the DER→raw conversion
|
||||||
|
assumes P-256, 32-byte coordinates).
|
||||||
|
|
||||||
|
## 5. Required IAM policy
|
||||||
|
|
||||||
|
The delta `--check` reports is the set of grants the deploying
|
||||||
|
principal needs **in addition** to the existing `nova-spike-runner`
|
||||||
|
grants. The full required set:
|
||||||
|
|
||||||
|
| Action | Why |
|
||||||
|
|--------|-----|
|
||||||
|
| `cloudformation:*` | create/deploy/describe the `nova-idp` stack |
|
||||||
|
| `codeartifact:*` | (already on `nova-spike-runner`) publish the wheel + layer |
|
||||||
|
| `iam:CreateRole` | create the per-Lambda execution roles |
|
||||||
|
| `iam:PassRole` | pass those roles to Lambda + CloudFormation |
|
||||||
|
| `lambda:CreateFunction` | create the 3 Lambda functions |
|
||||||
|
| `lambda:CreateFunctionUrlConfig` | create the 3 function URLs |
|
||||||
|
| `dynamodb:CreateTable` | create the 4 DDB tables (with PITR) |
|
||||||
|
| `kms:CreateKey` | mint the `ECC_NIST_P256` signing key |
|
||||||
|
| `kms:CreateAlias` | bind `alias/nova-oidc-signing` to the key |
|
||||||
|
| `ssm:PutParameter` | write the layer-version mapping to SSM |
|
||||||
|
|
||||||
|
Attach these to the deploying principal's policy before `--apply`.
|
||||||
|
`--check` will then report an empty `missing` list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-6.3 Grill additions — operational runbooks
|
||||||
|
|
||||||
|
The grill (C-6.3) requires four operational procedures beyond the
|
||||||
|
setup flow. Each is a runbook an on-call SRE can follow without reading
|
||||||
|
source code.
|
||||||
|
|
||||||
|
### 6. KMS key rotation (90-day cadence)
|
||||||
|
|
||||||
|
**Cadence:** rotate `alias/nova-oidc-signing` every **90 days**. The
|
||||||
|
rotation is a *key re-point*, not a key deletion — the alias is moved
|
||||||
|
to a new key while the old key stays valid during the token-overlap
|
||||||
|
window so already-issued tokens keep verifying.
|
||||||
|
|
||||||
|
**Procedure:**
|
||||||
|
|
||||||
|
1. **Create the new key** (same spec):
|
||||||
|
```sh
|
||||||
|
NEW_KEY=$(aws kms create-key \
|
||||||
|
--key-spec ECC_NIST_P256 \
|
||||||
|
--key-usage SIGN_VERIFY \
|
||||||
|
--description "nova-oidc-signing-$(date +%Y%m%d)" \
|
||||||
|
--query KeyId --output text)
|
||||||
|
```
|
||||||
|
2. **Re-point the alias** to the new key:
|
||||||
|
```sh
|
||||||
|
aws kms update-alias --alias-name alias/nova-oidc-signing \
|
||||||
|
--target-key-id "$NEW_KEY"
|
||||||
|
```
|
||||||
|
3. **JWKS serves both `kid`s during the overlap window.** The JWKS
|
||||||
|
Lambda lists **all** keys the alias has pointed at that are still
|
||||||
|
enabled. Already-issued OIDC tokens (signed with the old key) keep
|
||||||
|
verifying until they expire (OIDC TTL default 15 min; PAT TTL ≤ 24h
|
||||||
|
dev / ≤ 1h service-account). **Do not disable the old key until at
|
||||||
|
least the max PAT TTL (24h) has elapsed.**
|
||||||
|
4. **After the overlap window** (≥ 24h), disable + schedule deletion of
|
||||||
|
the old key:
|
||||||
|
```sh
|
||||||
|
aws kms disable-key --key-id "<old-key-id>"
|
||||||
|
aws kms schedule-key-deletion --key-id "<old-key-id>" --pending-window-in-days 7
|
||||||
|
```
|
||||||
|
5. **Verify** the new key is active:
|
||||||
|
```sh
|
||||||
|
nova idp setup --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
**Audit:** emit a manual `kms.key_rotated` event to the audit stream
|
||||||
|
with `old_key_id`, `new_key_id`, `rotated_at`. The rotation is a
|
||||||
|
CloudFormation-less operation (KMS aliases are mutable); it does not
|
||||||
|
require a stack update.
|
||||||
|
|
||||||
|
### 7. Lambda layer update
|
||||||
|
|
||||||
|
The `nova-cli` Lambda layer (the shared dependency bundle:
|
||||||
|
`argon2-cffi`, `cryptography`, `pyjwt`, `kj` binary) is republished
|
||||||
|
**automatically on every merge to `main`** by the P1 Wave 4 publish
|
||||||
|
workflow (the byte-identical GitHub + internal-forge workflow files).
|
||||||
|
On a successful publish, the new layer version ARN is written to SSM
|
||||||
|
`/nova/layer/nova-cli/version`.
|
||||||
|
|
||||||
|
**When to update manually:**
|
||||||
|
- A dependency CVE requires an out-of-band patch before the next merge.
|
||||||
|
- The `kj` binary pinned version changes (C-8.2 supply-chain safety).
|
||||||
|
|
||||||
|
**Manual procedure:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nova layer update
|
||||||
|
```
|
||||||
|
|
||||||
|
This rebuilds the layer (`pip install --target layer/python/` + the
|
||||||
|
pinned `kj` binary, SHA256 verified against `layer/kj.sha256`),
|
||||||
|
publishes a new `lambda:PublishLayerVersion`, and updates the SSM
|
||||||
|
parameter. The 3 Nova-idp Lambdas pick up the new layer on their next
|
||||||
|
cold start (or force a redeploy with `aws lambda update-function-configuration
|
||||||
|
--layers <new-arn>` on each).
|
||||||
|
|
||||||
|
**Verify:** `nova idp setup --verify` after the Lambdas reload.
|
||||||
|
|
||||||
|
### 8. DynamoDB PITR restore
|
||||||
|
|
||||||
|
All 4 identity tables have point-in-time recovery (PITR) enabled
|
||||||
|
(REQ-335): `nova-users`, `nova-sessions`, `nova-password-resets`,
|
||||||
|
`nova-pats`. PITR lets you restore a table to any second in the last
|
||||||
|
**35 days** (the AWS retention window).
|
||||||
|
|
||||||
|
**Procedure (restore `nova-pats` to 1 hour ago):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Find the restore target time (ISO 8601, UTC, within the last 35d).
|
||||||
|
RESTORE_TO=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
|
# 2. Restore to a NEW table (PITR never overwrites the source).
|
||||||
|
aws dynamodb restore-table-to-point-in-time \
|
||||||
|
--source-table-name nova-pats \
|
||||||
|
--target-table-name nova-pats-restored \
|
||||||
|
--restore-date-time "$RESTORE_TO" \
|
||||||
|
--billing-mode-restore-as-is
|
||||||
|
|
||||||
|
# 3. After the restore completes (status ACTIVE), repoint the app:
|
||||||
|
# - update the stack env var NOVA_PATS_TABLE=nova-pats-restored, or
|
||||||
|
# - rename: delete nova-pats, then aws dynamodb update-table --table-name
|
||||||
|
# nova-pats-restored --new-table-name nova-pats (downtime window).
|
||||||
|
# 4. Re-enable PITR on the restored table (PITR does not carry over).
|
||||||
|
aws dynamodb update-continuous-backups \
|
||||||
|
--table-name nova-pats-restored \
|
||||||
|
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Which tables have PITR:** all 4 (`nova-users`, `nova-sessions`,
|
||||||
|
`nova-password-resets`, `nova-pats`). Verify with:
|
||||||
|
```sh
|
||||||
|
for t in nova-users nova-sessions nova-password-resets nova-pats; do
|
||||||
|
aws dynamodb describe-continuous-backups --table-name "$t" \
|
||||||
|
--query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription' --output text
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recovery window:** 35 days (AWS PITR). Restores older than 35 days
|
||||||
|
are impossible — for longer retention, export to S3 via the on-demand
|
||||||
|
export or a scheduled AWS Backup plan.
|
||||||
|
|
||||||
|
### 9. Emergency PAT revocation (DDB-level, not CLI)
|
||||||
|
|
||||||
|
**When to use:** a PAT is known-compromised and the `nova auth revoke`
|
||||||
|
CLI is unavailable (e.g. the operator machine is offline, or the PAT
|
||||||
|
`jti` is known but the raw PAT is not — revocation is keyed on `jti`,
|
||||||
|
not the token string). This is a **DDB-level** operation; it bypasses
|
||||||
|
the CLI but still satisfies the D-229 strong-read SLO (the token-vend
|
||||||
|
Lambda does a `ConsistentRead=True` `GetItem` on `jti` on every vend —
|
||||||
|
the revocation is reflected on the next vend, within 60s P95).
|
||||||
|
|
||||||
|
**Procedure:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
aws dynamodb update-item \
|
||||||
|
--table-name nova-pats \
|
||||||
|
--key '{"jti":{"S":"<jti>"}}' \
|
||||||
|
--update-expression "SET #s = :r" \
|
||||||
|
--expression-attribute-names '{"#s":"status"}' \
|
||||||
|
--expression-attribute-values '{":r":{"S":"revoked"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<jti>` with the PAT's `jti` claim (a uuid4; find it in the
|
||||||
|
`pat.issued` audit event or by scanning the `sub-index` GSI for the
|
||||||
|
compromised subject). The item is **retained** (not deleted) so the
|
||||||
|
audit trail is intact — only `status` flips from `active` to `revoked`.
|
||||||
|
|
||||||
|
**Verify the revocation took effect:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
aws dynamodb get-item \
|
||||||
|
--table-name nova-pats \
|
||||||
|
--key '{"jti":{"S":"<jti>"}}' \
|
||||||
|
--consistent-read \
|
||||||
|
--query 'Item.status.S' --output text
|
||||||
|
# → revoked
|
||||||
|
```
|
||||||
|
|
||||||
|
The next `token-vend` call with that `jti` returns `403
|
||||||
|
pat_revoked` immediately (D-229: the strong read is synchronous).
|
||||||
|
|
||||||
|
**Bulk revocation** (revoke all of a subject's PATs):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SUB="<sub>"
|
||||||
|
JTIS=$(aws dynamodb query \
|
||||||
|
--table-name nova-pats \
|
||||||
|
--index-name sub-index \
|
||||||
|
--key-condition-expression "sub = :s" \
|
||||||
|
--expression-attribute-values "{\":s\":{\"S\":\"$SUB\"}}" \
|
||||||
|
--query 'Items[?status.S==`active`].jti.S' --output text)
|
||||||
|
for jti in $JTIS; do
|
||||||
|
aws dynamodb update-item --table-name nova-pats \
|
||||||
|
--key "{\"jti\":{\"S\":\"$jti\"}}" \
|
||||||
|
--update-expression "SET #s = :r" \
|
||||||
|
--expression-attribute-names '{"#s":"status"}' \
|
||||||
|
--expression-attribute-values '{":r":{"S":"revoked"}}'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix — quick reference
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|--------------|
|
||||||
|
| `nova idp setup --check` | prerequisites + IAM delta (no changes) |
|
||||||
|
| `nova idp setup --dry-run` | resource summary only (no deploy) |
|
||||||
|
| `nova idp setup --apply` | review template → `y/N` → deploy |
|
||||||
|
| `nova idp setup --apply --public-jwks-domain <fqdn>` | add CloudFront + WAF + ACM |
|
||||||
|
| `nova idp setup --verify` | KMS round-trip test (CAP-037) |
|
||||||
|
|
||||||
|
| Runbook | Cadence / trigger |
|
||||||
|
|---------|-------------------|
|
||||||
|
| KMS key rotation | every 90 days |
|
||||||
|
| Lambda layer update | on merge (auto) or manually via `nova layer update` |
|
||||||
|
| DDB PITR restore | on data loss / corruption (35-day window) |
|
||||||
|
| Emergency PAT revocation | on compromise (DDB-level, immediate) |
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# Nova Identity Layer — Threat Model
|
||||||
|
|
||||||
|
> **REQ-347** — identity-layer threat model. Covers the 8 threats
|
||||||
|
> enumerated below + the **C-9.2 INV-18..21 compression audit**. The
|
||||||
|
> C-6.2 grill additions (JWKS DDoS, PAT max TTL, ABAC fail-closed) are
|
||||||
|
> integrated into the threat list, not appended.
|
||||||
|
>
|
||||||
|
> Scope: the Nova-idp identity layer (`nova-idp-auth` +
|
||||||
|
> `nova-idp-token-vend` + `nova-idp-jwks` Lambdas, the KMS signing key,
|
||||||
|
> the 4 DynamoDB tables, the `nova auth` CLI, the PAT lifecycle). Out
|
||||||
|
> of scope: the downstream contract resolver, Terraform adapter, and
|
||||||
|
> consumer-side auth (those have their own threat models).
|
||||||
|
|
||||||
|
## 1. Assets
|
||||||
|
|
||||||
|
| Asset | Where | Sensitivity |
|
||||||
|
|-------|-------|-------------|
|
||||||
|
| User passwords | `nova-users.password_hash` (Argon2id) | high — hash only; raw never stored |
|
||||||
|
| PATs (personal access tokens) | `nova-pats` (hash only) + returned to caller once | high — bearer token, ≤24h/≤1h TTL |
|
||||||
|
| OIDC tokens | `~/.nova/credentials.json` (0600) + in-flight to clients | medium — short-lived (15 min default) |
|
||||||
|
| KMS signing key | KMS `alias/nova-oidc-signing` (`ECC_NIST_P256`) | high — the trust anchor for all OIDC tokens |
|
||||||
|
| ABAC policy | `platform/abac/token-vend.policy` (git-tracked) | high — the authorization rules |
|
||||||
|
| DynamoDB tables | `nova-users`, `nova-sessions`, `nova-password-resets`, `nova-pats` | high — the identity store |
|
||||||
|
| JWKS endpoint | `nova-idp-jwks` function URL (`AuthType: NONE`) | medium — public, must be available but is not secret |
|
||||||
|
| Audit stream | stderr JSON from each Lambda + the CLI | high — tamper-evidence for the whole layer |
|
||||||
|
|
||||||
|
## 2. Trust boundaries
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────┐ IAM-auth function URL ┌────────────────────┐
|
||||||
|
│ Developer CI │ ───────────────────────────► │ nova-idp-auth │
|
||||||
|
│ (nova CLI) │ │ nova-idp-token-vend│
|
||||||
|
│ │ ◄────── OIDC token ───────── │ (KMS sign) │
|
||||||
|
└────────┬───────┘ └─────────┬──────────┘
|
||||||
|
│ │
|
||||||
|
│ ~/.nova/credentials.json (0600) │ strong-read GetItem
|
||||||
|
│ NOT the raw PAT ▼
|
||||||
|
│ ┌────────────────────┐
|
||||||
|
│ │ nova-pats (DDB) │
|
||||||
|
│ │ nova-users/sessions│
|
||||||
|
│ JWKS fetch (unauthenticated) └────────────────────┘
|
||||||
|
│ ──────────────────────────────────► ┌────────────────────┐
|
||||||
|
│ │ nova-idp-jwks │
|
||||||
|
│ ◄──── public key (JWK) ──────────── │ (AuthType: NONE) │
|
||||||
|
▼ └────────────────────┘
|
||||||
|
┌────────────────┐
|
||||||
|
│ AWS KMS │ kms:Sign (token-vend role only)
|
||||||
|
│ alias/nova- │ kms:GetPublicKey (jwks role)
|
||||||
|
│ oidc-signing │
|
||||||
|
└────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The key boundary crossings:
|
||||||
|
1. **Internet → JWKS Lambda** (unauthenticated function URL) — the
|
||||||
|
DDoS surface (Threat T-4).
|
||||||
|
2. **CLI → auth/token-vend Lambdas** (IAM-authenticated function URLs)
|
||||||
|
— the credential-injection surface.
|
||||||
|
3. **token-vend Lambda → KMS** (`kms:Sign`) — the key-use surface.
|
||||||
|
4. **token-vend Lambda → DDB** (strong read on `nova-pats`) — the
|
||||||
|
revocation surface.
|
||||||
|
|
||||||
|
## 3. Threats + mitigations
|
||||||
|
|
||||||
|
### T-1 — Password compromise (storage)
|
||||||
|
|
||||||
|
**Threat:** an attacker with read access to `nova-users` (DDB export,
|
||||||
|
backup, a leaked snapshot) recovers plaintext passwords.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- **Argon2id hashing** with OWASP-minimum parameters
|
||||||
|
(`time_cost=3, memory_cost=65536 KiB, parallelism=1`) —
|
||||||
|
`core/lambda/nova_idp_auth.py:hash_password`. Argon2id is the
|
||||||
|
recommended PHC winner; the parameters are the OWASP minimum (C-7.2).
|
||||||
|
- **Fail-closed on Argon2 unavailable** (D-228): if the `argon2-cffi`
|
||||||
|
C extension fails to import, `_ARGON2_AVAILABLE` is `False` and
|
||||||
|
`hash_password`/`verify_password` raise `Argon2UnavailableError` →
|
||||||
|
the handler returns **503**. **No pure-Python fallback, no weak
|
||||||
|
hash, no crash.** Verified by `tests/test_argon2_fail_closed.py`.
|
||||||
|
- **No raw passwords anywhere** (INV-16): the handler never logs the
|
||||||
|
password argument; the audit scrubber (`_emit_audit`) pops any
|
||||||
|
`password`/`new_password`/`old_password` kwarg defense-in-depth;
|
||||||
|
the DDB item has `password_hash`, never `password`. Verified by
|
||||||
|
`tests/test_idp_auth.py:TestNoRawPasswordsInLogs`.
|
||||||
|
|
||||||
|
**Residual risk:** low. Argon2id with the OWASP params is
|
||||||
|
GPU-resistant at scale; the remaining risk is a parameter-weakness
|
||||||
|
advisory (mitigated by the 90-day KMS rotation cadence's analog for
|
||||||
|
hash params — revisit annually).
|
||||||
|
|
||||||
|
### T-2 — PAT theft + max TTL (C-6.2)
|
||||||
|
|
||||||
|
**Threat:** an attacker exfiltrates a PAT (filesystem read of
|
||||||
|
`~/.nova/credentials.json`, a leaked CI env var, a phishing capture)
|
||||||
|
and uses it to vend OIDC tokens until it expires.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- **`~/.nova/credentials.json` stores the OIDC token + PAT metadata
|
||||||
|
(`jti`, `exp`, `type`) ONLY — NOT the raw PAT** (C-7.3). The raw PAT
|
||||||
|
is entered once at `nova auth login` and never persisted. An attacker
|
||||||
|
who reads the credentials file gets a short-lived OIDC token (15 min
|
||||||
|
default), not the long-lived PAT. Verified by
|
||||||
|
`tests/test_auth_commands.py:test_login_stores_oidc_token_not_raw_pat`.
|
||||||
|
- **Max TTL (C-6.2):** developer PATs ≤ 24h (86400s), service-account
|
||||||
|
PATs ≤ 1h (3600s). Enforced in `core.pat_lifecycle.issue_pat` —
|
||||||
|
requests above the max are clamped (with an audit event). The shorter
|
||||||
|
service-account TTL bounds the CI blast radius.
|
||||||
|
- **Revocation via strong-read DDB (D-229):** the token-vend Lambda
|
||||||
|
does `GetItem(PK=jti, ConsistentRead=True)` on `nova-pats` on every
|
||||||
|
vend. A revocation (`status=revoked`) is reflected on the next vend
|
||||||
|
within **60s P95** (the strong read is synchronous — the 60s is the
|
||||||
|
P95 propagation bound, not a polling delay). Verified by
|
||||||
|
`tests/test_pat_revocation.py:test_pat_revocation_slo` (asserts
|
||||||
|
`<1s` locally).
|
||||||
|
- **Emergency revocation at the DDB level** (when the CLI is
|
||||||
|
unavailable): `aws dynamodb update-item --table-name nova-pats ...`
|
||||||
|
flips `status` to `revoked` — see `docs/operator-guide-idp.md` §9.
|
||||||
|
|
||||||
|
**Residual risk:** medium. The PAT is a bearer token — theft is
|
||||||
|
undetectable until the attacker vends a token. Mitigation is TTL
|
||||||
|
bounding + revocation, not prevention. The 1h service-account cap is
|
||||||
|
the primary control for CI exposure.
|
||||||
|
|
||||||
|
### T-3 — JWKS unauthenticated endpoint DDoS (C-6.2)
|
||||||
|
|
||||||
|
**Threat:** the JWKS endpoint (`nova-idp-jwks` function URL,
|
||||||
|
`AuthType: NONE`) is a public, unauthenticated target. An attacker can
|
||||||
|
flood it with requests, exhausting Lambda concurrency and making token
|
||||||
|
verification fail for all clients (a cheap DoS).
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- **Reserved concurrency (10, max ~100 RPS):** the JWKS Lambda has a
|
||||||
|
reserved-concurrency limit of 10 (set in the CloudFormation
|
||||||
|
template). This caps the blast radius — a flood saturates the JWKS
|
||||||
|
Lambda but does NOT exhaust the account-wide concurrency pool, so
|
||||||
|
`nova-idp-auth` and `nova-idp-token-vend` keep serving.
|
||||||
|
- **Client-side caching (1h):** the JWKS response carries
|
||||||
|
`Cache-Control: max-age=3600`. Clients (`pyjwt.PyJWK` client) cache
|
||||||
|
the keys for 1h, so a JWKS outage does not immediately break
|
||||||
|
verification — already-cached keys keep working.
|
||||||
|
- **Optional CloudFront + WAF (rate-based rule):** `nova idp setup
|
||||||
|
--apply --public-jwks-domain <fqdn>` fronts the function URL with a
|
||||||
|
CloudFront distribution + a WAF web ACL with a rate-based rule
|
||||||
|
(e.g. block an IP after 2000 req/5min). **For any public deployment,
|
||||||
|
set `--public-jwks-domain`.** Without it the function URL is bare —
|
||||||
|
fine for piloting, exposed for production.
|
||||||
|
|
||||||
|
**Residual risk:** medium. The reserved concurrency bounds the cost
|
||||||
|
but a determined attacker can still keep the JWKS Lambda saturated.
|
||||||
|
The WAF + CloudFront path is the production-grade control. JWKS is
|
||||||
|
inherently public (clients MUST fetch it without auth) — this is a
|
||||||
|
fundamental OIDC property, not a Nova design flaw.
|
||||||
|
|
||||||
|
### T-4 — ABAC bypass (C-6.1 / C-7.1)
|
||||||
|
|
||||||
|
**Threat:** the ABAC policy engine (`kyverno-json` / `kj`) fails to
|
||||||
|
load, crashes, or is misconfigured, and the token-vend Lambda vends a
|
||||||
|
token anyway (fails open). This would bypass the authorization gate —
|
||||||
|
every active PAT gets a token regardless of the policy.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- **Fail-closed (C-6.1/C-7.1 — the grill's #1 finding):** the
|
||||||
|
token-vend Lambda's `_evaluate_abac_fail_closed` returns
|
||||||
|
`(False, [], "", "abac_eval_failed")` if:
|
||||||
|
- `KyvernoJsonEngine.is_configured()` returns `False` (`kj` absent),
|
||||||
|
- `get_engine()` raises (engine registry error),
|
||||||
|
- `evaluate_token_vend_policy()` raises (policy parse error, `kj`
|
||||||
|
runtime error).
|
||||||
|
In all three cases the Lambda returns **403** + an audit event
|
||||||
|
`token.vend.denied` (reason `abac_eval_failed`). **Never fails open.**
|
||||||
|
This is INV-17's runtime guarantee — without it, INV-17 is
|
||||||
|
documentation, not a control.
|
||||||
|
- **Verified by `tests/test_abac_fail_closed.py` (7 tests):**
|
||||||
|
engine-not-configured, evaluate-raises, policy-parse-error, ABAC
|
||||||
|
denies, revoked PAT, unknown PAT, audit-event-emitted-on-denial.
|
||||||
|
- **Policy version in every audit event (D-231):** the git blob SHA of
|
||||||
|
`platform/abac/token-vend.policy` is recorded in every
|
||||||
|
`token.vend.allowed`/`token.vend.denied` event. An auditor can
|
||||||
|
reconstruct which policy version governed each vend.
|
||||||
|
|
||||||
|
**Residual risk:** low (given the fail-closed semantics). The
|
||||||
|
remaining risk is a policy-authoring bug (the policy allows too much)
|
||||||
|
— mitigated by PR review (D-231: Platform Security owns the policy)
|
||||||
|
and the policy-version audit trail.
|
||||||
|
|
||||||
|
### T-5 — KMS signing key compromise
|
||||||
|
|
||||||
|
**Threat:** an attacker gains `kms:Sign` permission on
|
||||||
|
`alias/nova-oidc-signing` and forges OIDC tokens.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- **KMS key policy restricts `kms:Sign` to the token-vend Lambda
|
||||||
|
role.** No other principal (including the operator) can sign. The
|
||||||
|
JWKS Lambda role has `kms:GetPublicKey` only (not `Sign`).
|
||||||
|
- **Key rotation (90 days):** the alias is re-pointed to a new
|
||||||
|
`ECC_NIST_P256` key every 90 days (see
|
||||||
|
`docs/operator-guide-idp.md` §6). The old key stays enabled during
|
||||||
|
the overlap window (≥ max PAT TTL = 24h) so already-issued tokens
|
||||||
|
keep verifying, then is disabled + scheduled for deletion.
|
||||||
|
- **JWKS serves both `kid`s during the overlap window:** the JWKS
|
||||||
|
endpoint lists all keys the alias has pointed at that are still
|
||||||
|
enabled. Clients verify against the `kid` in the token header.
|
||||||
|
|
||||||
|
**Residual risk:** low. KMS key policies are the primary control;
|
||||||
|
rotation bounds the exposure window of a stolen key.
|
||||||
|
|
||||||
|
### T-6 — DER → raw ECDSA signature conversion bug (C-5.2 gotcha)
|
||||||
|
|
||||||
|
**Threat:** KMS `sign()` returns a **DER-encoded** ASN.1 ECDSA
|
||||||
|
signature. JWS (RFC 7515 §3.1.3) requires the **raw** `r‖s`
|
||||||
|
concatenation, each coordinate 32 bytes big-endian (for P-256). If the
|
||||||
|
conversion is wrong (wrong byte order, wrong padding, wrong coordinate
|
||||||
|
length), the resulting JWT will not verify with standard libraries
|
||||||
|
(`pyjwt`, `jose`) — or worse, verifies with a *different* signature
|
||||||
|
than intended (a subtle correctness + security bug).
|
||||||
|
|
||||||
|
This is the **#1 implementation risk** identified in RESEARCH §5. The
|
||||||
|
conversion is in `core/kms_signing.py:der_to_raw_ecdsa`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
r, s = decode_dss_signature(der_sig) # cryptography's ASN.1 parser
|
||||||
|
return r.to_bytes(32, "big") + s.to_bytes(32, "big") # raw r‖s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- **`decode_dss_signature` from `cryptography`** parses the DER (not a
|
||||||
|
hand-rolled ASN.1 parser — that would be the real risk).
|
||||||
|
- **`to_bytes(32, "big")` zero-pads** each coordinate to exactly 32
|
||||||
|
bytes. A coordinate shorter than 32 bytes (high-order zero bytes)
|
||||||
|
is padded; a coordinate longer than 32 bytes raises `ValueError`
|
||||||
|
(the guard at the top of `der_to_raw_ecdsa`).
|
||||||
|
- **Verified by `tests/test_kms_roundtrip.py` (CAP-037):** sign a JWT
|
||||||
|
via `kms_signing.sign_jwt()` (mock KMS with a test ECC keypair) →
|
||||||
|
fetch JWKS via the JWKS Lambda → verify with `pyjwt` + the JWKS key.
|
||||||
|
The round-trip succeeds only if the DER→raw conversion is
|
||||||
|
byte-correct. This is the regression gate for any change to
|
||||||
|
`kms_signing.py`.
|
||||||
|
|
||||||
|
**Residual risk:** low (given the round-trip test). A KMS-side format
|
||||||
|
change (AWS changes the DER encoding) would break the test loudly.
|
||||||
|
|
||||||
|
### T-7 — No AWS-managed identity (INV-15)
|
||||||
|
|
||||||
|
**Threat:** (architectural invariant, not an attack.) Nova-idp depends
|
||||||
|
on Cognito, IAM Identity Center, or another AWS-managed identity
|
||||||
|
service, creating a vendor lock-in and an opaque trust boundary.
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
- **INV-15 (no AWS-managed identity in path):** Nova-idp uses **KMS +
|
||||||
|
DDB + Lambda only.** No Cognito, no IAM Identity Center, no managed
|
||||||
|
user pools. The identity layer is greenfield and fully owned by
|
||||||
|
Nova. This is a constraint, not a mitigation — it shapes the whole
|
||||||
|
design (Argon2id in Lambda instead of Cognito user pools; KMS-signed
|
||||||
|
JWTs instead of Cognito issued tokens; DDB `nova-pats` instead of
|
||||||
|
IAM access keys).
|
||||||
|
- **Verified by inspection:** `core/lambda/nova_idp_auth.py` +
|
||||||
|
`nova_idp_token_vend.py` import only `boto3` (DDB + KMS), `argon2`,
|
||||||
|
`cryptography`, `pyjwt`, and `core.*`. No `cognitoidp` or
|
||||||
|
`identitystore` client calls anywhere in the identity layer.
|
||||||
|
|
||||||
|
**Residual risk:** none (this is a satisfied constraint, not a
|
||||||
|
residual). The trade-off is operational burden (Nova runs its own
|
||||||
|
password hashing, token signing, revocation) in exchange for
|
||||||
|
portability and no opaque trust boundary.
|
||||||
|
|
||||||
|
### T-8 — Audit trail integrity
|
||||||
|
|
||||||
|
**Threat:** an attacker tampers with the audit stream to hide a
|
||||||
|
malicious vend, a revocation, or a policy change.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- **Every event emitted (INV-12):** `cli.invocation`, `auth.sign_up`,
|
||||||
|
`auth.sign_in`, `auth.session_created`, `pat.issued`, `pat.revoked`,
|
||||||
|
`token.vend.allowed`, `token.vend.denied`, `auth.login`,
|
||||||
|
`auth.status`, `auth.revoke` — each is a JSON line on stderr with a
|
||||||
|
timestamp + the relevant identifiers (`user_id`, `jti`, `sub`,
|
||||||
|
`policy_sha`).
|
||||||
|
- **Policy version (git SHA, D-231) in every token-vend event:** the
|
||||||
|
`policy_sha` field lets an auditor reconstruct which policy version
|
||||||
|
governed each vend — a policy change is visible in the audit stream
|
||||||
|
as a `policy_sha` change.
|
||||||
|
- **Raw credentials scrubbed (INV-16/INV-17 spirit):** the
|
||||||
|
`_emit_audit` functions in `nova_idp_auth.py`,
|
||||||
|
`nova_idp_token_vend.py`, and `pat_lifecycle.py` pop any
|
||||||
|
`password`/`pat`/`token`/`raw_pat` kwarg defense-in-depth. The audit
|
||||||
|
stream carries identifiers, not secrets.
|
||||||
|
- **Revoked PATs retained (REQ-343):** `nova-pats` rows are marked
|
||||||
|
`status=revoked`, never deleted. The audit trail of "who was
|
||||||
|
revoked, when" is queryable.
|
||||||
|
|
||||||
|
**Residual risk:** medium (audit integrity is only as strong as the
|
||||||
|
log destination). The Lambdas emit to stderr (CloudWatch Logs by
|
||||||
|
default); the integrity guarantee depends on the downstream log
|
||||||
|
pipeline (immutability, retention). For high-assurance deployments,
|
||||||
|
forward the audit stream to an append-only store (S3 Object Lock, a
|
||||||
|
write-once log service). This is a deployment concern, documented in
|
||||||
|
the operator guide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. C-9.2 — INV-18..21 compression audit
|
||||||
|
|
||||||
|
The source spec (the v1.28 design document that was re-mapped into this
|
||||||
|
repo's REQ-323..353 / INV-12..17 — see `REQUIREMENTS.md` §v1.28 "ID
|
||||||
|
re-mapping") referenced `INV-18..21` as "attestation invariants."
|
||||||
|
Those IDs **do not exist in this repo** (this repo's invariants run
|
||||||
|
INV-1..11 for the blockchain/pilot work and INV-12..17 for v1.28). The
|
||||||
|
grill (C-9.2) requires an audit verifying the spec's attestation
|
||||||
|
invariant semantics were fully captured by the re-mapped
|
||||||
|
INV-15/INV-16/INV-17 + REQ-332, with no semantic gap.
|
||||||
|
|
||||||
|
### The spec's attestation invariant semantics (reconstructed)
|
||||||
|
|
||||||
|
The source spec's INV-18..21 expressed four attestation concerns:
|
||||||
|
|
||||||
|
1. **Immutability** — an attestation, once made, cannot be silently
|
||||||
|
altered.
|
||||||
|
2. **Signature verifiability** — the attestation's signature can be
|
||||||
|
independently verified by a third party holding the public key.
|
||||||
|
3. **Key derivation** — the signing key is derived from a known input
|
||||||
|
(the PAT) via a specified KDF, not ad-hoc.
|
||||||
|
4. **No AWS-managed identity** — the attestation scheme does not
|
||||||
|
depend on Cognito / IAM Identity Center (the greenfield constraint).
|
||||||
|
|
||||||
|
### Mapping to the re-mapped invariants + requirements
|
||||||
|
|
||||||
|
| Spec concern | Re-mapped to | Where enforced |
|
||||||
|
|--------------|--------------|----------------|
|
||||||
|
| Immutability | **INV-6** (existing, pre-v1.28 — the immutable audit ledger) + **INV-17** (ABAC discipline — every vend is audited with `policy_sha`) | the audit stream is append-only; `policy_sha` binds each vend to a policy version |
|
||||||
|
| Signature verifiability | **REQ-332** (JWS-from-PAT KDF) + **REQ-337** (KMS-signed OIDC, JWKS verifiable) | `core/jws_attestation.py:verify_attestation` (HS256, constant-time compare); `core/kms_signing.py` + JWKS endpoint |
|
||||||
|
| Key derivation | **REQ-332** (C-5.2 grill fix) — `HKDF-SHA256(PAT, salt='nova-local-attestation', info='jws-signing-key')` → 32-byte symmetric key | `core/jws_attestation.py:derive_signing_key`; verified by `tests/test_jws_attestation.py` |
|
||||||
|
| No AWS-managed identity | **INV-15** (no Cognito / IAM Identity Center in path) | inspection — the identity layer uses KMS + DDB + Lambda only |
|
||||||
|
|
||||||
|
### Conclusion: the compression is sound — no semantic gap
|
||||||
|
|
||||||
|
The spec's four attestation concerns are covered by:
|
||||||
|
- **INV-6** (immutability — the existing audit ledger, carried forward
|
||||||
|
from pre-v1.28 milestones),
|
||||||
|
- **INV-15** (no AWS-managed identity — the greenfield constraint),
|
||||||
|
- **INV-16** (password storage — the Argon2id + no-raw-password rule,
|
||||||
|
which is the attestation *input* integrity for signup),
|
||||||
|
- **INV-17** (ABAC discipline — every vend is policy-gated + audited
|
||||||
|
with `policy_sha`),
|
||||||
|
- **REQ-332** (JWS-from-PAT KDF — the signature + key-derivation
|
||||||
|
scheme for local-review attestations).
|
||||||
|
|
||||||
|
The re-mapping from `INV-18..21` → `INV-15/16/17 + REQ-332` is a
|
||||||
|
**compression** (4 invariants → 3 invariants + 1 requirement), not a
|
||||||
|
**drop**. The four original concerns (immutability, signature
|
||||||
|
verifiability, key derivation, no-managed-identity) each have a
|
||||||
|
load-bearing home in the re-mapped set. **No attestation invariant
|
||||||
|
semantics were silently dropped.**
|
||||||
|
|
||||||
|
The compression is *justified* because:
|
||||||
|
- INV-6 already covered audit immutability (re-stating it as INV-18
|
||||||
|
would have been a duplicate of an existing invariant).
|
||||||
|
- INV-15 already covered the no-managed-identity constraint
|
||||||
|
(re-stating it as INV-21 would have been a duplicate).
|
||||||
|
- INV-16 + INV-17 cover the input-integrity + policy-discipline
|
||||||
|
concerns that the spec's INV-19/20 expressed as attestation-specific
|
||||||
|
invariants (they are in fact general identity-layer invariants, not
|
||||||
|
attestation-specific).
|
||||||
|
- REQ-332 carries the signature + KDF detail that the spec's INV-18
|
||||||
|
hand-waved ("public key derivable from the PAT") — and corrects it
|
||||||
|
to a sound symmetric scheme (C-5.2).
|
||||||
|
|
||||||
|
### Audit verification (how to re-run this audit)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Confirm INV-18..21 do not exist in this repo.
|
||||||
|
grep -rE 'INV-1[89]|INV-2[01]' .ciagent/ docs/ core/ tests/ \
|
||||||
|
| grep -v 'INV-18..21' # only the C-9.2 audit references should remain
|
||||||
|
|
||||||
|
# 2. Confirm the re-mapped invariants + REQ-332 exist + are tested.
|
||||||
|
pytest tests/test_jws_attestation.py tests/test_abac_fail_closed.py \
|
||||||
|
tests/test_kms_roundtrip.py tests/test_argon2_fail_closed.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Test coverage summary
|
||||||
|
|
||||||
|
| Threat | Test file | What it verifies |
|
||||||
|
|--------|-----------|------------------|
|
||||||
|
| T-1 (password) | `tests/test_argon2_fail_closed.py` | 503 on argon2 unavailable (no weak hash) |
|
||||||
|
| T-1 (password) | `tests/test_idp_auth.py` | no raw password in DDB item or logs (INV-16) |
|
||||||
|
| T-2 (PAT theft) | `tests/test_auth_commands.py` | credentials.json has OIDC token, NOT raw PAT (C-7.3) |
|
||||||
|
| T-2 (PAT theft) | `tests/test_pat_revocation.py` | revocation takes effect <1s (D-229 SLO) |
|
||||||
|
| T-3 (JWKS DDoS) | (CloudFormation template inspection) | reserved concurrency = 10; WAF with `--public-jwks-domain` |
|
||||||
|
| T-4 (ABAC bypass) | `tests/test_abac_fail_closed.py` (7 tests) | fail-closed on engine absent / error / deny (C-6.1) |
|
||||||
|
| T-5 (KMS key) | `tests/test_kms_roundtrip.py` | KMS sign → JWKS → pyjwt verify (CAP-037) |
|
||||||
|
| T-6 (DER→raw) | `tests/test_kms_roundtrip.py` | the round-trip succeeds only if DER→raw is byte-correct |
|
||||||
|
| T-7 (no managed id) | (inspection) | no `cognitoidp` / `identitystore` imports in the identity layer |
|
||||||
|
| T-8 (audit) | `tests/test_e2e_idp.py` | the full audit chain is present + linked (REQ-348) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Open items (deferred, not blocking v1.28)
|
||||||
|
|
||||||
|
- **WAF rate-limit tuning:** the default rate-based rule threshold
|
||||||
|
(2000 req/5min/IP) is a pilot-scale guess. Production tuning needs
|
||||||
|
real traffic data. Tracked as a post-v1.28 ops task.
|
||||||
|
- **Audit log forwarding to an append-only store** (S3 Object Lock):
|
||||||
|
the Lambdas emit to stderr / CloudWatch Logs by default. High-
|
||||||
|
assurance deployments should forward to a write-once destination.
|
||||||
|
Documented in the operator guide; not enforced in code.
|
||||||
|
- **PAT theft detection:** there is no anomaly detection on PAT usage
|
||||||
|
(e.g. a vend from a new geography). The TTL + revocation is the
|
||||||
|
control. Detection is a future milestone.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""nova auth — login / revoke / status subcommands (REQ-344, C-7.3).
|
||||||
|
|
||||||
|
Subpackage entry point: ``add_parser`` registers the ``auth`` subparser
|
||||||
|
with ``login``/``revoke``/``status`` sub-subcommands, each delegating to
|
||||||
|
its module's ``run``. Discovered by ``nova/cli.py`` via
|
||||||
|
``pkgutil.iter_modules`` (this package's ``add_parser`` is the hook).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("auth", help="Nova IdP auth (login/revoke/status)")
|
||||||
|
sub = p.add_subparsers(dest="auth_command", required=True)
|
||||||
|
from nova.auth import login as _login, revoke as _revoke, status as _status
|
||||||
|
_login.add_parser(sub)
|
||||||
|
_revoke.add_parser(sub)
|
||||||
|
_status.add_parser(sub)
|
||||||
|
return p
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""nova auth login — session/PAT → OIDC token, store locally (REQ-344, C-7.3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from core.auth_store import store_credential, credentials_path
|
||||||
|
|
||||||
|
|
||||||
|
def _vend(pat: str, env: str, endpoint: str) -> dict:
|
||||||
|
"""Call the token-vend Lambda (locally or via the function URL)."""
|
||||||
|
if endpoint and endpoint.startswith("http"):
|
||||||
|
import urllib.request
|
||||||
|
body = json.dumps({"token": pat, "environment": env}).encode()
|
||||||
|
req = urllib.request.Request(endpoint, data=body, headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
# Local: invoke the Lambda in-process.
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
p = Path(__import__("core").__file__).parent / "lambda" / "nova_idp_token_vend.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("nova_idp_token_vend", p)
|
||||||
|
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||||
|
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||||
|
resp = mod.lambda_handler({"body": json.dumps({"token": pat, "environment": env})}, None)
|
||||||
|
return json.loads(resp["body"])
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("login", help="exchange a PAT/session for an OIDC token")
|
||||||
|
p.add_argument("--pat", default=None, help="PAT JWT (prompted if absent)")
|
||||||
|
p.add_argument("--session", default=None, help="session token (alias for --pat)")
|
||||||
|
p.add_argument("--environment", default="dev", help="target environment")
|
||||||
|
p.add_argument("--endpoint", default=os.environ.get("NOVA_TOKEN_VEND_URL", ""),
|
||||||
|
help="token-vend function URL (empty = local)")
|
||||||
|
p.set_defaults(_run=run)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args) -> int:
|
||||||
|
pat = args.pat or args.session or os.environ.get("NOVA_PAT")
|
||||||
|
if not pat:
|
||||||
|
pat = sys.stdin.readline().strip()
|
||||||
|
if not pat:
|
||||||
|
print("error: no PAT/session provided", file=sys.stderr); return 1
|
||||||
|
result = _vend(pat, args.environment, args.endpoint)
|
||||||
|
if "token" not in result:
|
||||||
|
print(f"error: {result.get('error', result)}", file=sys.stderr); return 2
|
||||||
|
import base64
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(result["token"].split(".")[1] + "=="))
|
||||||
|
store_credential(
|
||||||
|
jti=payload.get("jti", ""), cred_type=payload.get("typ", "nova_oidc_token"),
|
||||||
|
exp=payload.get("exp", 0), oidc_token=result["token"],
|
||||||
|
)
|
||||||
|
print(f"logged in: jti={payload.get('jti')} exp={payload.get('exp')} "
|
||||||
|
f"file={credentials_path()}")
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""nova auth revoke --pat <jti> — revoke a PAT (REQ-344, D-229)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from core.auth_store import emit_revoke_audit
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("revoke", help="revoke a PAT by jti")
|
||||||
|
p.add_argument("--pat", required=True, help="PAT jti to revoke")
|
||||||
|
p.add_argument("--endpoint", default=os.environ.get("NOVA_TOKEN_VEND_URL", ""),
|
||||||
|
help="token-vend function URL (empty = local DDB)")
|
||||||
|
p.set_defaults(_run=run)
|
||||||
|
|
||||||
|
|
||||||
|
def _revoke_remote(jti: str, endpoint: str) -> dict:
|
||||||
|
import json, urllib.request
|
||||||
|
body = json.dumps({"action": "revoke_pat", "jti": jti}).encode()
|
||||||
|
req = urllib.request.Request(endpoint, data=body, headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def run(args) -> int:
|
||||||
|
try:
|
||||||
|
if args.endpoint and args.endpoint.startswith("http"):
|
||||||
|
_revoke_remote(args.pat, args.endpoint)
|
||||||
|
else:
|
||||||
|
from core.pat_lifecycle import revoke_pat
|
||||||
|
revoke_pat(args.pat)
|
||||||
|
emit_revoke_audit(args.pat)
|
||||||
|
print(f"revoked: jti={args.pat}")
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr); return 2
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""nova auth status — print active credential + mode (REQ-344)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from core.auth_store import active_credential, emit_status_audit
|
||||||
|
from core.mode_resolver import resolve_mode_from_env
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("status", help="show active credential + client mode")
|
||||||
|
p.set_defaults(_run=run)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args) -> int:
|
||||||
|
cred = active_credential()
|
||||||
|
emit_status_audit()
|
||||||
|
mode, reason = resolve_mode_from_env(
|
||||||
|
credential_type=cred.get("type") if cred else None,
|
||||||
|
)
|
||||||
|
if cred is None:
|
||||||
|
print(f"no active credential (mode={mode}, reason={reason})")
|
||||||
|
return 0
|
||||||
|
print(json.dumps({
|
||||||
|
"active_credential_jti": cred.get("jti"),
|
||||||
|
"type": cred.get("type"),
|
||||||
|
"exp": cred.get("exp"),
|
||||||
|
"mode": mode,
|
||||||
|
"selection_reason": reason,
|
||||||
|
}, indent=2))
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""nova idp — IdP setup subcommands (REQ-340, C-2.1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("idp", help="Nova IdP management (setup)")
|
||||||
|
sub = p.add_subparsers(dest="idp_command", required=True)
|
||||||
|
from nova.idp import setup as _setup
|
||||||
|
_setup.add_parser(sub)
|
||||||
|
return p
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
v0.0.3
|
||||||
|
4ebb9a19fbf545e17f046c137f9b69c4288d021e5c73d962835671e0cb3fbf07
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""ABAC fail-closed test for the token-vend Lambda (C-6.1/C-7.1, INV-17).
|
||||||
|
|
||||||
|
🔴 THIS IS THE MOST IMPORTANT TEST OF THE MILESTONE. It verifies that
|
||||||
|
INV-17 (ABAC fail-closed) is a **runtime guarantee**, not just
|
||||||
|
documentation. The grill's #1 finding was that a naive implementation
|
||||||
|
could fail open (vend a token when the ABAC engine is broken). This
|
||||||
|
test pins the opposite: **every** ABAC failure mode → 403 +
|
||||||
|
``token.vend.denied`` (reason ``abac_eval_failed``). Never fail open.
|
||||||
|
|
||||||
|
Failure modes covered:
|
||||||
|
1. ``KyvernoJsonEngine.is_configured()`` returns ``False`` (kj absent).
|
||||||
|
2. ``evaluate_token_vend_policy()`` raises an exception (kj error,
|
||||||
|
policy parse error, subprocess crash).
|
||||||
|
3. ABAC denies (allowed=False) → 403 reason ``abac_denied``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
# moto requires a region; the Lambda's lazy boto3.resource("dynamodb")
|
||||||
|
# picks up AWS_DEFAULT_REGION.
|
||||||
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
|
os.environ.setdefault("AWS_ACCESS_KEY_ID", "test")
|
||||||
|
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||||
|
|
||||||
|
# Load the token-vend Lambda via importlib (`lambda` is a reserved word).
|
||||||
|
_SOURCE_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_token_vend.py"
|
||||||
|
)
|
||||||
|
_spec = importlib.util.spec_from_file_location("nova_idp_token_vend", _SOURCE_PATH)
|
||||||
|
tv = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(tv)
|
||||||
|
|
||||||
|
# Load nova_idp_auth_cfn table helpers + moto for DDB.
|
||||||
|
import boto3
|
||||||
|
from moto import mock_aws
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pats_table(ddb):
|
||||||
|
ddb.create_table(
|
||||||
|
TableName="nova-pats",
|
||||||
|
KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[
|
||||||
|
{"AttributeName": "jti", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "sub", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "pat_hash", "AttributeType": "S"},
|
||||||
|
],
|
||||||
|
GlobalSecondaryIndexes=[
|
||||||
|
{"IndexName": "sub-index", "KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||||
|
{"IndexName": "pat_hash-index", "KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||||
|
],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_lambda_singletons():
|
||||||
|
"""Reset the Lambda's module-level DynamoDB singleton before each test."""
|
||||||
|
tv._dynamodb = None
|
||||||
|
yield
|
||||||
|
tv._dynamodb = None
|
||||||
|
|
||||||
|
|
||||||
|
def _put_active_pat(ddb, jti="pat-active", sub="user-1", owner="t1", role="developer"):
|
||||||
|
ddb.put_item(
|
||||||
|
TableName="nova-pats",
|
||||||
|
Item={
|
||||||
|
"jti": {"S": jti},
|
||||||
|
"sub": {"S": sub},
|
||||||
|
"pat_hash": {"S": "hash-" + jti},
|
||||||
|
"status": {"S": "active"},
|
||||||
|
"issued_at": {"S": "2026-01-01T00:00:00Z"},
|
||||||
|
"expires_at": {"N": str(int(time.time()) + 3600)},
|
||||||
|
"claims": {"S": json.dumps({"sub": sub, "roles": [role], "owner": owner})},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pat_jwt(jti="pat-active", sub="user-1", role="developer", owner="t1"):
|
||||||
|
"""Build an unsigned-ish JWT (signature irrelevant — decoded without verify)."""
|
||||||
|
import base64
|
||||||
|
header = base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||||
|
payload = base64.urlsafe_b64encode(json.dumps({
|
||||||
|
"jti": jti, "sub": sub, "exp": int(time.time()) + 3600,
|
||||||
|
"iat": int(time.time()), "roles": [role], "owner": owner,
|
||||||
|
"typ": "developer_pat",
|
||||||
|
}).encode()).rstrip(b"=").decode()
|
||||||
|
return f"{header}.{payload}.sig"
|
||||||
|
|
||||||
|
|
||||||
|
def _vend_event(pat_jwt, **extra):
|
||||||
|
body = {"token": pat_jwt, "environment": "dev", "target_resource": {"type": "contract", "id": "c1", "owner": "t1", "environment": "dev"}, "requested_claims": ["sub"]}
|
||||||
|
body.update(extra)
|
||||||
|
return {"body": json.dumps(body)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 🔴 THE CRITICAL TESTS — fail closed on every ABAC failure mode.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_fail_closed_when_kj_not_configured():
|
||||||
|
"""C-6.1: is_configured() == False → 403 + abac_eval_failed. NEVER fail open."""
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
_put_active_pat(ddb)
|
||||||
|
pat = _make_pat_jwt()
|
||||||
|
# Mock the engine so is_configured() returns False (kj absent).
|
||||||
|
fake_engine = mock.MagicMock()
|
||||||
|
fake_engine.is_configured.return_value = False
|
||||||
|
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine):
|
||||||
|
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||||
|
assert resp["statusCode"] == 403
|
||||||
|
body = json.loads(resp["body"])
|
||||||
|
assert body["reason"] == "abac_eval_failed"
|
||||||
|
assert body["error"] == "token_vend_denied"
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_fail_closed_when_evaluate_raises():
|
||||||
|
"""C-6.1: evaluate() raises → 403 + abac_eval_failed. NEVER fail open."""
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
_put_active_pat(ddb)
|
||||||
|
pat = _make_pat_jwt()
|
||||||
|
fake_engine = mock.MagicMock()
|
||||||
|
fake_engine.is_configured.return_value = True
|
||||||
|
# evaluate_token_vend_policy is called inside _evaluate_abac_fail_closed;
|
||||||
|
# patch the core.abac_evaluator module to raise.
|
||||||
|
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||||
|
mock.patch("core.abac_evaluator.evaluate_token_vend_policy",
|
||||||
|
side_effect=RuntimeError("kj crashed")):
|
||||||
|
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||||
|
assert resp["statusCode"] == 403
|
||||||
|
body = json.loads(resp["body"])
|
||||||
|
assert body["reason"] == "abac_eval_failed"
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_fail_closed_when_policy_parse_error():
|
||||||
|
"""C-6.1: policy parse error (evaluate raises ValueError) → 403."""
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
_put_active_pat(ddb)
|
||||||
|
pat = _make_pat_jwt()
|
||||||
|
fake_engine = mock.MagicMock()
|
||||||
|
fake_engine.is_configured.return_value = True
|
||||||
|
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||||
|
mock.patch("core.abac_evaluator.evaluate_token_vend_policy",
|
||||||
|
side_effect=ValueError("policy parse error")):
|
||||||
|
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||||
|
assert resp["statusCode"] == 403
|
||||||
|
assert json.loads(resp["body"])["reason"] == "abac_eval_failed"
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_fail_closed_when_abac_denies():
|
||||||
|
"""ABAC denies (allowed=False) → 403 + abac_denied (distinct from eval_failed)."""
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
_put_active_pat(ddb)
|
||||||
|
pat = _make_pat_jwt()
|
||||||
|
fake_engine = mock.MagicMock()
|
||||||
|
fake_engine.is_configured.return_value = True
|
||||||
|
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||||
|
mock.patch("core.abac_evaluator.evaluate_token_vend_policy",
|
||||||
|
return_value=(False, [], "sha")):
|
||||||
|
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||||
|
assert resp["statusCode"] == 403
|
||||||
|
assert json.loads(resp["body"])["reason"] == "abac_denied"
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_fail_closed_revoked_pat():
|
||||||
|
"""D-229: revoked PAT → 403 + pat_revoked (before ABAC even runs)."""
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
_put_active_pat(ddb, jti="pat-rev")
|
||||||
|
ddb.update_item(
|
||||||
|
TableName="nova-pats",
|
||||||
|
Key={"jti": {"S": "pat-rev"}},
|
||||||
|
UpdateExpression="SET #s = :v",
|
||||||
|
ExpressionAttributeNames={"#s": "status"},
|
||||||
|
ExpressionAttributeValues={":v": {"S": "revoked"}},
|
||||||
|
)
|
||||||
|
pat = _make_pat_jwt(jti="pat-rev")
|
||||||
|
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||||
|
assert resp["statusCode"] == 403
|
||||||
|
assert json.loads(resp["body"])["reason"] == "pat_revoked"
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_fail_closed_unknown_pat():
|
||||||
|
"""D-229: PAT not in table → 403 + pat_unknown."""
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
pat = _make_pat_jwt(jti="pat-missing")
|
||||||
|
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||||
|
assert resp["statusCode"] == 403
|
||||||
|
assert json.loads(resp["body"])["reason"] == "pat_unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_audit_event_emitted_on_denial(capsys):
|
||||||
|
"""token.vend.denied audit event is emitted on every denial (INV-17)."""
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
_put_active_pat(ddb)
|
||||||
|
pat = _make_pat_jwt()
|
||||||
|
fake_engine = mock.MagicMock()
|
||||||
|
fake_engine.is_configured.return_value = False
|
||||||
|
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine):
|
||||||
|
tv.lambda_handler(_vend_event(pat), None)
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
audit_lines = [l for l in err.strip().split("\n") if l.strip()]
|
||||||
|
denied = [json.loads(l) for l in audit_lines if json.loads(l).get("event") == "token.vend.denied"]
|
||||||
|
assert denied, "expected a token.vend.denied audit event"
|
||||||
|
assert denied[0]["reason"] == "abac_eval_failed"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""ABAC policy tests for the token-vend Lambda (REQ-339, C-5.1, C-6.1).
|
||||||
|
|
||||||
|
Uses the **real** ``kj`` binary at ``/usr/local/bin/kj`` — these are
|
||||||
|
real policy-evaluation tests, not mocked. Skipped when ``kj`` is absent
|
||||||
|
(graceful, not failed — the binary is a build-host dep).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from core.abac_evaluator import evaluate_token_vend_policy
|
||||||
|
|
||||||
|
KJ_AVAILABLE = shutil.which("kj") is not None
|
||||||
|
skip_no_kj = pytest.mark.skipif(
|
||||||
|
not KJ_AVAILABLE, reason="`kj` binary not on PATH (D-227 build-host dep)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(role, env, owner="t1", res_owner="t1"):
|
||||||
|
return {
|
||||||
|
"subject": {"id": "u1", "role": role, "owner": owner},
|
||||||
|
"requested_claims": ["sub", "roles"],
|
||||||
|
"target_resource": {
|
||||||
|
"type": "contract",
|
||||||
|
"id": "c1",
|
||||||
|
"owner": res_owner,
|
||||||
|
"environment": env,
|
||||||
|
},
|
||||||
|
"environment": env,
|
||||||
|
"pat_jti": "p1",
|
||||||
|
"policy_version": "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@skip_no_kj
|
||||||
|
def test_developer_dev_allowed():
|
||||||
|
allowed, pcrs, sha = evaluate_token_vend_policy(_payload("developer", "dev"))
|
||||||
|
assert allowed is True, [p for p in pcrs if p["result"] == "fail"]
|
||||||
|
assert sha # non-empty SHA
|
||||||
|
|
||||||
|
|
||||||
|
@skip_no_kj
|
||||||
|
def test_sre_prod_allowed():
|
||||||
|
allowed, pcrs, sha = evaluate_token_vend_policy(_payload("sre", "prod"))
|
||||||
|
assert allowed is True, [p for p in pcrs if p["result"] == "fail"]
|
||||||
|
|
||||||
|
|
||||||
|
@skip_no_kj
|
||||||
|
def test_sre_qa_allowed():
|
||||||
|
allowed, _, _ = evaluate_token_vend_policy(_payload("sre", "qa"))
|
||||||
|
assert allowed is True
|
||||||
|
|
||||||
|
|
||||||
|
@skip_no_kj
|
||||||
|
def test_sre_dr_allowed():
|
||||||
|
allowed, _, _ = evaluate_token_vend_policy(_payload("sre", "dr"))
|
||||||
|
assert allowed is True
|
||||||
|
|
||||||
|
|
||||||
|
@skip_no_kj
|
||||||
|
def test_developer_prod_denied():
|
||||||
|
allowed, pcrs, _ = evaluate_token_vend_policy(_payload("developer", "prod"))
|
||||||
|
assert allowed is False
|
||||||
|
fails = [p for p in pcrs if p["result"] == "fail" and p["severity"] == "critical"]
|
||||||
|
assert fails, "expected at least one critical fail PCR"
|
||||||
|
|
||||||
|
|
||||||
|
@skip_no_kj
|
||||||
|
def test_wrong_owner_denied():
|
||||||
|
allowed, pcrs, _ = evaluate_token_vend_policy(
|
||||||
|
_payload("developer", "dev", owner="t1", res_owner="t2")
|
||||||
|
)
|
||||||
|
assert allowed is False
|
||||||
|
fails = [p for p in pcrs if p["result"] == "fail"]
|
||||||
|
assert fails
|
||||||
|
|
||||||
|
|
||||||
|
@skip_no_kj
|
||||||
|
def test_developer_qa_denied():
|
||||||
|
allowed, _, _ = evaluate_token_vend_policy(_payload("developer", "qa"))
|
||||||
|
assert allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
@skip_no_kj
|
||||||
|
def test_empty_requested_claims_denied():
|
||||||
|
pl = _payload("developer", "dev")
|
||||||
|
pl["requested_claims"] = []
|
||||||
|
allowed, pcrs, _ = evaluate_token_vend_policy(pl)
|
||||||
|
assert allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
@skip_no_kj
|
||||||
|
def test_policy_sha_is_string():
|
||||||
|
_, _, sha = evaluate_token_vend_policy(_payload("developer", "dev"))
|
||||||
|
assert isinstance(sha, str)
|
||||||
|
assert len(sha) > 0
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""nova auth login/revoke/status tests (REQ-344, C-7.3).
|
||||||
|
|
||||||
|
C-7.3: ``~/.nova/credentials.json`` stores OIDC token + PAT metadata
|
||||||
|
(jti, exp, type) ONLY — NOT the raw PAT. Verified by asserting the
|
||||||
|
file contains no ``raw_pat`` / ``pat`` field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
|
os.environ.setdefault("AWS_ACCESS_KEY_ID", "test")
|
||||||
|
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from moto import mock_aws
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
|
||||||
|
import core.kms_signing as kms_signing
|
||||||
|
import core.pat_lifecycle as pat_life
|
||||||
|
import core.auth_store as auth_store
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test keypair + mock KMS.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _MockKms:
|
||||||
|
def __init__(self, priv, pub_der):
|
||||||
|
self._priv = priv
|
||||||
|
self._pub_der = pub_der
|
||||||
|
|
||||||
|
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||||
|
return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||||
|
|
||||||
|
def get_public_key(self, KeyId):
|
||||||
|
return {"PublicKey": self._pub_der}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_keypair():
|
||||||
|
priv = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
pub = priv.public_key()
|
||||||
|
pub_der = pub.public_bytes(
|
||||||
|
encoding=serialization.Encoding.DER,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
return priv, pub, pub_der
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pats_table(ddb):
|
||||||
|
ddb.create_table(
|
||||||
|
TableName="nova-pats",
|
||||||
|
KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[
|
||||||
|
{"AttributeName": "jti", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "sub", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "pat_hash", "AttributeType": "S"},
|
||||||
|
],
|
||||||
|
GlobalSecondaryIndexes=[
|
||||||
|
{"IndexName": "sub-index", "KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||||
|
{"IndexName": "pat_hash-index", "KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||||
|
],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _cred_file(tmp_path, monkeypatch):
|
||||||
|
"""Isolate credentials.json to a tmp path."""
|
||||||
|
cred = tmp_path / "credentials.json"
|
||||||
|
monkeypatch.setenv("NOVA_CREDENTIALS_FILE", str(cred))
|
||||||
|
yield cred
|
||||||
|
kms_signing.set_kms_client_for_testing(None)
|
||||||
|
pat_life._dynamodb = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# nova auth login
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_login_stores_oidc_token_not_raw_pat(tmp_path, test_keypair, _cred_file):
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
# Issue a PAT.
|
||||||
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||||
|
assert pat # raw PAT returned once
|
||||||
|
# Run nova auth login via the local Lambda path.
|
||||||
|
from nova.auth import login as login_mod
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.pat = pat
|
||||||
|
args.session = None
|
||||||
|
args.environment = "dev"
|
||||||
|
args.endpoint = "" # local
|
||||||
|
rc = login_mod.run(args)
|
||||||
|
assert rc == 0
|
||||||
|
# Assert credentials.json exists + is 0600.
|
||||||
|
assert _cred_file.exists()
|
||||||
|
mode = stat.S_IMODE(os.stat(_cred_file).st_mode)
|
||||||
|
assert mode == 0o600
|
||||||
|
data = json.loads(_cred_file.read_text())
|
||||||
|
# C-7.3: contains the OIDC token + metadata, NOT the raw PAT.
|
||||||
|
cred = data["credentials"][0]
|
||||||
|
assert "token" in cred # the OIDC token
|
||||||
|
assert cred["type"] == "nova_oidc_token"
|
||||||
|
assert "jti" in cred and "exp" in cred
|
||||||
|
raw = _cred_file.read_text()
|
||||||
|
assert "raw_pat" not in raw
|
||||||
|
assert pat not in raw # the raw PAT string must NOT appear
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_login_denied_pat_returns_error(tmp_path, test_keypair, _cred_file):
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||||
|
# Revoke it.
|
||||||
|
# Extract jti from the PAT.
|
||||||
|
import base64
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
|
||||||
|
pat_life.revoke_pat(payload["jti"])
|
||||||
|
from nova.auth import login as login_mod
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = ""
|
||||||
|
rc = login_mod.run(args)
|
||||||
|
assert rc != 0 # denied
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# nova auth status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_no_credential(_cred_file, capsys):
|
||||||
|
from nova.auth import status as status_mod
|
||||||
|
rc = status_mod.run(mock.MagicMock())
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "no active credential" in out
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_status_shows_mode_and_jti(tmp_path, test_keypair, _cred_file, capsys):
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||||
|
from nova.auth import login as login_mod
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = ""
|
||||||
|
login_mod.run(args)
|
||||||
|
capsys.readouterr() # drain login output
|
||||||
|
from nova.auth import status as status_mod
|
||||||
|
rc = status_mod.run(mock.MagicMock())
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
data = json.loads(out)
|
||||||
|
assert "mode" in data
|
||||||
|
assert "selection_reason" in data
|
||||||
|
assert data["type"] == "nova_oidc_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# nova auth revoke
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_revoke_sets_status_revoked(tmp_path, test_keypair, _cred_file, capsys):
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||||
|
import base64
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
|
||||||
|
jti = payload["jti"]
|
||||||
|
from nova.auth import revoke as revoke_mod
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.pat = jti; args.endpoint = ""
|
||||||
|
rc = revoke_mod.run(args)
|
||||||
|
assert rc == 0
|
||||||
|
# Verify status=revoked in DDB.
|
||||||
|
item = ddb.get_item(TableName="nova-pats", Key={"jti": {"S": jti}}, ConsistentRead=True)
|
||||||
|
assert item["Item"]["status"]["S"] == "revoked"
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
"""E2E integration test — sign-up → sign-in → token-vend → apply → audit
|
||||||
|
(REQ-348, J1+J2 happy path combined).
|
||||||
|
|
||||||
|
This is the P5 Wave 2 integration test. It exercises the full Nova-idp
|
||||||
|
identity chain end-to-end against moto (DynamoDB) + a mock KMS (a test
|
||||||
|
ECC keypair). In CI against a deployed Nova-idp it would hit the real
|
||||||
|
Lambdas; locally it uses direct function calls (the dual-use
|
||||||
|
``dispatch_action`` / ``vend_token`` entry points, REQ-329).
|
||||||
|
|
||||||
|
The flow (REQ-348):
|
||||||
|
|
||||||
|
1. sign_up(email, password) → user in nova-users (Argon2id hash)
|
||||||
|
2. sign_in(email, password) → session_id in nova-sessions
|
||||||
|
3. issue a PAT (pat_lifecycle.issue_pat) → raw PAT returned once
|
||||||
|
4. nova auth login (token-vend) → KMS-signed OIDC token
|
||||||
|
5. verify the OIDC token against the JWKS key (pyjwt)
|
||||||
|
6. nova apply --local --sign-local-review → JWS attestation (HS256)
|
||||||
|
7. verify the JWS attestation with the PAT-derived key
|
||||||
|
8. assert the audit chain is complete + linked
|
||||||
|
|
||||||
|
Asserts (a)–(g) from the task spec are mapped to the test methods below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
|
os.environ.setdefault("AWS_ACCESS_KEY_ID", "test")
|
||||||
|
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||||
|
os.environ.setdefault("NOVA_REPO_ROOT", str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Load the Lambda modules via importlib (`lambda` is a Python reserved word
|
||||||
|
# — mirrors tests/test_idp_auth.py / test_token_vend.py).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: Path, name: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
idp_auth = _load(_REPO / "core" / "lambda" / "nova_idp_auth.py", "nova_idp_auth_e2e")
|
||||||
|
token_vend = _load(_REPO / "core" / "lambda" / "nova_idp_token_vend.py", "nova_idp_token_vend_e2e")
|
||||||
|
jwks_mod = _load(_REPO / "core" / "lambda" / "nova_idp_jwks.py", "nova_idp_jwks_e2e")
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from moto import mock_aws
|
||||||
|
import jwt as pyjwt
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
|
||||||
|
import core.kms_signing as kms_signing
|
||||||
|
import core.pat_lifecycle as pat_life
|
||||||
|
import core.jws_attestation as jws_attestation
|
||||||
|
import core.env as env_mod
|
||||||
|
from core.contract_resolver import resolve
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock KMS (a test ECC keypair — same pattern as test_kms_roundtrip.py).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _MockKms:
|
||||||
|
def __init__(self, priv, pub_der):
|
||||||
|
self._priv = priv
|
||||||
|
self._pub_der = pub_der
|
||||||
|
|
||||||
|
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||||
|
return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||||
|
|
||||||
|
def get_public_key(self, KeyId):
|
||||||
|
return {"PublicKey": self._pub_der}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Table creation (the 4 IdP tables).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _create_idp_tables(ddb):
|
||||||
|
"""Create the 4 IdP tables (nova-users, nova-sessions,
|
||||||
|
nova-password-resets, nova-pats) with the GSIs the auth + PAT code
|
||||||
|
expects."""
|
||||||
|
ddb.create_table(
|
||||||
|
TableName="nova-users",
|
||||||
|
KeySchema=[{"AttributeName": "user_id", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[
|
||||||
|
{"AttributeName": "user_id", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "email", "AttributeType": "S"},
|
||||||
|
],
|
||||||
|
GlobalSecondaryIndexes=[
|
||||||
|
{
|
||||||
|
"IndexName": "email-index",
|
||||||
|
"KeySchema": [{"AttributeName": "email", "KeyType": "HASH"}],
|
||||||
|
"Projection": {"ProjectionType": "ALL"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
ddb.create_table(
|
||||||
|
TableName="nova-sessions",
|
||||||
|
KeySchema=[{"AttributeName": "session_id", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[
|
||||||
|
{"AttributeName": "session_id", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "user_id", "AttributeType": "S"},
|
||||||
|
],
|
||||||
|
GlobalSecondaryIndexes=[
|
||||||
|
{
|
||||||
|
"IndexName": "user_id-index",
|
||||||
|
"KeySchema": [{"AttributeName": "user_id", "KeyType": "HASH"}],
|
||||||
|
"Projection": {"ProjectionType": "ALL"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
ddb.create_table(
|
||||||
|
TableName="nova-password-resets",
|
||||||
|
KeySchema=[{"AttributeName": "reset_token", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[{"AttributeName": "reset_token", "AttributeType": "S"}],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
ddb.create_table(
|
||||||
|
TableName="nova-pats",
|
||||||
|
KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[
|
||||||
|
{"AttributeName": "jti", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "sub", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "pat_hash", "AttributeType": "S"},
|
||||||
|
],
|
||||||
|
GlobalSecondaryIndexes=[
|
||||||
|
{"IndexName": "sub-index",
|
||||||
|
"KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}],
|
||||||
|
"Projection": {"ProjectionType": "ALL"}},
|
||||||
|
{"IndexName": "pat_hash-index",
|
||||||
|
"KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}],
|
||||||
|
"Projection": {"ProjectionType": "ALL"}},
|
||||||
|
],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_keypair():
|
||||||
|
priv = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
pub = priv.public_key()
|
||||||
|
pub_der = pub.public_bytes(
|
||||||
|
encoding=serialization.Encoding.DER,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
return priv, pub, pub_der
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_modules():
|
||||||
|
"""Reset the cached boto3 singletons + the mock KMS client."""
|
||||||
|
idp_auth._dynamodb = None
|
||||||
|
token_vend._dynamodb = None
|
||||||
|
pat_life._dynamodb = None
|
||||||
|
yield
|
||||||
|
idp_auth._dynamodb = None
|
||||||
|
token_vend._dynamodb = None
|
||||||
|
pat_life._dynamodb = None
|
||||||
|
kms_signing.set_kms_client_for_testing(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cred_file(tmp_path, monkeypatch):
|
||||||
|
"""Isolate ~/.nova/credentials.json to a tmp path (C-7.3)."""
|
||||||
|
p = tmp_path / "credentials.json"
|
||||||
|
monkeypatch.setenv("NOVA_CREDENTIALS_FILE", str(p))
|
||||||
|
yield p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_contract(tmp_path):
|
||||||
|
"""A minimal contract YAML that resolve() + synthesize_local_env()
|
||||||
|
can consume (mirrors tests/test_local_env.py's fixture)."""
|
||||||
|
contract = """
|
||||||
|
id: msvc
|
||||||
|
name: microservice
|
||||||
|
environment: dev
|
||||||
|
infrastructure:
|
||||||
|
microservice:
|
||||||
|
version: "1.0.0"
|
||||||
|
inputs:
|
||||||
|
image: nginx:latest
|
||||||
|
"""
|
||||||
|
p = tmp_path / "contract.yml"
|
||||||
|
p.write_text(contract)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audit-event capture (the Lambdas emit JSON lines on stderr).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _AuditCapture:
|
||||||
|
"""Capture JSON audit lines written to stderr by the Lambda modules.
|
||||||
|
|
||||||
|
Each Lambda's ``_emit_audit`` does ``sys.stderr.write(json + "\\n")``.
|
||||||
|
We replace the module's ``sys`` reference's stderr with a StringIO
|
||||||
|
during the flow, then parse the captured lines back into dicts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.events: list[dict] = []
|
||||||
|
self._buf = io.StringIO()
|
||||||
|
self._real_stderr = sys.stderr
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
# Patch sys.stderr globally for the duration — the Lambda modules
|
||||||
|
# all use the module-level `sys` import (sys.stderr.write).
|
||||||
|
sys.stderr = self._buf
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
sys.stderr = self._real_stderr
|
||||||
|
self._buf.seek(0)
|
||||||
|
for line in self._buf.getvalue().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self.events.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Non-JSON stderr noise (e.g. a traceback) — ignore.
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
def event_types(self) -> list[str]:
|
||||||
|
return [e.get("event", "") for e in self.events]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The E2E test (REQ-348).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestE2EIdpFlow:
|
||||||
|
"""E2E: sign-up → sign-in → token-vend → apply → audit (REQ-348).
|
||||||
|
|
||||||
|
Runs against moto (DynamoDB) + mock KMS locally; in CI the same
|
||||||
|
assertions run against the deployed Nova-idp Lambdas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_full_e2e_sign_up_sign_in_token_vend_apply_audit(
|
||||||
|
self, test_keypair, cred_file, sample_contract
|
||||||
|
):
|
||||||
|
priv, pub, pub_der = test_keypair
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_idp_tables(ddb)
|
||||||
|
|
||||||
|
email = "alice@example.com"
|
||||||
|
password = "E2E-Secret-12345"
|
||||||
|
owner = "team-a"
|
||||||
|
|
||||||
|
audit = _AuditCapture()
|
||||||
|
with audit:
|
||||||
|
# --- (a) sign_up succeeds ---
|
||||||
|
up = idp_auth.lambda_handler(
|
||||||
|
{
|
||||||
|
"body": json.dumps(
|
||||||
|
{
|
||||||
|
"action": "sign_up",
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"owner": owner,
|
||||||
|
"roles": ["developer"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert up["statusCode"] == 200, up
|
||||||
|
up_body = json.loads(up["body"])
|
||||||
|
user_id = up_body["user_id"]
|
||||||
|
assert user_id
|
||||||
|
|
||||||
|
# --- (b) sign_in returns a session ---
|
||||||
|
inn = idp_auth.lambda_handler(
|
||||||
|
{
|
||||||
|
"body": json.dumps(
|
||||||
|
{"action": "sign_in", "email": email, "password": password}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert inn["statusCode"] == 200, inn
|
||||||
|
session_id = json.loads(inn["body"])["session_id"]
|
||||||
|
assert session_id
|
||||||
|
|
||||||
|
# --- issue a PAT (the developer logs in with it) ---
|
||||||
|
pat = pat_life.issue_pat(
|
||||||
|
user_id, ["developer"], owner, ttl_seconds=3600,
|
||||||
|
subject_type="developer",
|
||||||
|
)
|
||||||
|
assert pat, "no raw PAT returned"
|
||||||
|
# Extract the PAT jti for later audit-link assertions.
|
||||||
|
pat_payload = json.loads(
|
||||||
|
base64.urlsafe_b64decode(pat.split(".")[1] + "==")
|
||||||
|
)
|
||||||
|
pat_jti = pat_payload["jti"]
|
||||||
|
assert pat_jti
|
||||||
|
|
||||||
|
# --- (c) token-vend returns an OIDC token ---
|
||||||
|
vend_body = {
|
||||||
|
"token": pat,
|
||||||
|
"environment": "dev",
|
||||||
|
"requested_claims": ["sub", "roles"],
|
||||||
|
"target_resource": {
|
||||||
|
"type": "contract", "id": "msvc",
|
||||||
|
"owner": owner, "environment": "dev",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vresp = token_vend.lambda_handler(
|
||||||
|
{"body": json.dumps(vend_body)}, None
|
||||||
|
)
|
||||||
|
assert vresp["statusCode"] == 200, vresp
|
||||||
|
oidc_token = json.loads(vresp["body"])["token"]
|
||||||
|
assert oidc_token
|
||||||
|
|
||||||
|
# --- (d) the OIDC token verifies with the JWKS key ---
|
||||||
|
jwks_resp = jwks_mod.lambda_handler({}, None)
|
||||||
|
assert jwks_resp["statusCode"] == 200, jwks_resp
|
||||||
|
jwk = json.loads(jwks_resp["body"])["keys"][0]
|
||||||
|
key = pyjwt.PyJWK(jwk).key
|
||||||
|
decoded_oidc = pyjwt.decode(
|
||||||
|
oidc_token, key, algorithms=["ES256"],
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
|
assert decoded_oidc["sub"] == user_id
|
||||||
|
assert decoded_oidc["typ"] == "nova_oidc_token"
|
||||||
|
assert decoded_oidc["roles"] == ["developer"]
|
||||||
|
assert "jti" in decoded_oidc and "exp" in decoded_oidc
|
||||||
|
|
||||||
|
# --- store the credential (nova auth login) ---
|
||||||
|
# Use the auth_store directly (login.py's local path calls
|
||||||
|
# token_vend in-process, which we already did above).
|
||||||
|
from core.auth_store import store_credential
|
||||||
|
store_credential(
|
||||||
|
jti=decoded_oidc["jti"],
|
||||||
|
cred_type=decoded_oidc["typ"],
|
||||||
|
exp=decoded_oidc["exp"],
|
||||||
|
oidc_token=oidc_token,
|
||||||
|
)
|
||||||
|
# C-7.3: the credentials file has the OIDC token, NOT the raw PAT.
|
||||||
|
raw_cred = cred_file.read_text()
|
||||||
|
assert "raw_pat" not in raw_cred
|
||||||
|
assert pat not in raw_cred
|
||||||
|
|
||||||
|
# --- (e) nova apply --local --sign-local-review produces a JWS ---
|
||||||
|
# Drive apply via the core functions directly (nova/apply.py's
|
||||||
|
# run() calls these; we skip the argparse layer for the test).
|
||||||
|
synth = env_mod.synthesize_local_env(
|
||||||
|
str(sample_contract), environment="dev"
|
||||||
|
)
|
||||||
|
assert synth["region"] == "local"
|
||||||
|
attestation_payload = {
|
||||||
|
"contract": str(sample_contract),
|
||||||
|
"review": "local",
|
||||||
|
"user_id": user_id,
|
||||||
|
"pat_jti": pat_jti,
|
||||||
|
}
|
||||||
|
jws = jws_attestation.sign_attestation(attestation_payload, pat)
|
||||||
|
assert jws.count(".") == 2, "not a compact JWS (3 segments)"
|
||||||
|
|
||||||
|
# --- (f) the JWS verifies with the PAT-derived key ---
|
||||||
|
verified = jws_attestation.verify_attestation(jws, pat)
|
||||||
|
assert verified == attestation_payload
|
||||||
|
|
||||||
|
# Tamper detection: verify with the wrong PAT raises.
|
||||||
|
with pytest.raises(jws_attestation.JWSValidationError):
|
||||||
|
jws_attestation.verify_attestation(jws, pat + "tampered")
|
||||||
|
|
||||||
|
# --- (g) the audit chain is complete + linked ---
|
||||||
|
# Every step emitted an audit event with the expected event type.
|
||||||
|
types = audit.event_types()
|
||||||
|
# sign_up + sign_in + session_created + pat.issued + token.vend.allowed
|
||||||
|
assert "auth.sign_up" in types, f"missing auth.sign_up in {types}"
|
||||||
|
assert "auth.sign_in" in types, f"missing auth.sign_in in {types}"
|
||||||
|
assert "auth.session_created" in types, f"missing auth.session_created in {types}"
|
||||||
|
assert "pat.issued" in types, f"missing pat.issued in {types}"
|
||||||
|
assert "token.vend.allowed" in types, f"missing token.vend.allowed in {types}"
|
||||||
|
|
||||||
|
# Linkage: the sign_up + sign_in events share the same user_id.
|
||||||
|
sign_up_ev = next(e for e in audit.events if e.get("event") == "auth.sign_up")
|
||||||
|
sign_in_ev = next(e for e in audit.events if e.get("event") == "auth.sign_in")
|
||||||
|
assert sign_up_ev["user_id"] == user_id
|
||||||
|
assert sign_in_ev["user_id"] == user_id
|
||||||
|
assert sign_up_ev["email"] == email
|
||||||
|
|
||||||
|
# Linkage: the pat.issued event carries the PAT jti + sub.
|
||||||
|
pat_issued_ev = next(e for e in audit.events if e.get("event") == "pat.issued")
|
||||||
|
assert pat_issued_ev["jti"] == pat_jti
|
||||||
|
assert pat_issued_ev["sub"] == user_id
|
||||||
|
|
||||||
|
# Linkage: the token.vend.allowed event carries the PAT jti + sub +
|
||||||
|
# policy_sha (D-231).
|
||||||
|
vend_ev = next(e for e in audit.events if e.get("event") == "token.vend.allowed")
|
||||||
|
assert vend_ev["pat_jti"] == pat_jti
|
||||||
|
assert vend_ev["sub"] == user_id
|
||||||
|
assert "policy_sha" in vend_ev
|
||||||
|
|
||||||
|
# Linkage: no raw password / PAT leaked into any audit event (INV-16).
|
||||||
|
for ev in audit.events:
|
||||||
|
blob = json.dumps(ev, sort_keys=True)
|
||||||
|
assert password not in blob, (
|
||||||
|
f"raw password leaked into audit event {ev.get('event')!r}: {blob}"
|
||||||
|
)
|
||||||
|
assert pat not in blob, (
|
||||||
|
f"raw PAT leaked into audit event {ev.get('event')!r}: {blob}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- the user item in nova-users has a password_hash, NOT the raw password ---
|
||||||
|
item = ddb.get_item(
|
||||||
|
TableName="nova-users", Key={"user_id": {"S": user_id}}
|
||||||
|
)
|
||||||
|
assert "Item" in item
|
||||||
|
attrs = item["Item"]
|
||||||
|
assert "password_hash" in attrs
|
||||||
|
assert attrs["password_hash"]["S"].startswith("$argon2id$")
|
||||||
|
assert "password" not in attrs, "raw password stored in DDB item!"
|
||||||
|
for key, val in attrs.items():
|
||||||
|
sval = val.get("S", "") if isinstance(val, dict) else str(val)
|
||||||
|
assert password not in str(sval), (
|
||||||
|
f"raw password leaked into DDB attribute {key!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- the PAT row in nova-pats has a hash, NOT the raw PAT ---
|
||||||
|
pat_item = ddb.get_item(
|
||||||
|
TableName="nova-pats",
|
||||||
|
Key={"jti": {"S": pat_jti}},
|
||||||
|
ConsistentRead=True,
|
||||||
|
)
|
||||||
|
assert "Item" in pat_item
|
||||||
|
assert pat_item["Item"]["status"]["S"] == "active"
|
||||||
|
assert "pat_hash" in pat_item["Item"]
|
||||||
|
raw_pat_blob = json.dumps(pat_item["Item"], sort_keys=True)
|
||||||
|
assert pat not in raw_pat_blob, "raw PAT stored in nova-pats item!"
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_e2e_revocation_breaks_the_chain(self, test_keypair, sample_contract):
|
||||||
|
"""The E2E chain breaks at token-vend after revocation (D-229).
|
||||||
|
|
||||||
|
Issue a PAT → revoke it → the next token-vend returns 403
|
||||||
|
pat_revoked (the audit event is token.vend.denied). This is the
|
||||||
|
negative path of the E2E flow — the revocation is the trust
|
||||||
|
anchor, not the JWT signature (D-229).
|
||||||
|
"""
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_idp_tables(ddb)
|
||||||
|
|
||||||
|
audit = _AuditCapture()
|
||||||
|
with audit:
|
||||||
|
pat = pat_life.issue_pat(
|
||||||
|
"user-2", ["developer"], "team-b", ttl_seconds=3600,
|
||||||
|
)
|
||||||
|
pat_payload = json.loads(
|
||||||
|
base64.urlsafe_b64decode(pat.split(".")[1] + "==")
|
||||||
|
)
|
||||||
|
pat_jti = pat_payload["jti"]
|
||||||
|
|
||||||
|
# Vend succeeds before revocation.
|
||||||
|
ok = token_vend.lambda_handler(
|
||||||
|
{"body": json.dumps({"token": pat, "environment": "dev"})},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert ok["statusCode"] == 200, ok
|
||||||
|
|
||||||
|
# Revoke.
|
||||||
|
pat_life.revoke_pat(pat_jti)
|
||||||
|
|
||||||
|
# Vend fails after revocation (403 pat_revoked, immediate — D-229).
|
||||||
|
denied = token_vend.lambda_handler(
|
||||||
|
{"body": json.dumps({"token": pat, "environment": "dev"})},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert denied["statusCode"] == 403, denied
|
||||||
|
assert json.loads(denied["body"])["reason"] == "pat_revoked"
|
||||||
|
|
||||||
|
types = audit.event_types()
|
||||||
|
assert "pat.issued" in types
|
||||||
|
assert "pat.revoked" in types
|
||||||
|
assert "token.vend.allowed" in types
|
||||||
|
assert "token.vend.denied" in types
|
||||||
|
|
||||||
|
# The denied event carries the revoked jti + the pat_revoked reason.
|
||||||
|
denied_ev = next(e for e in audit.events if e.get("event") == "token.vend.denied")
|
||||||
|
assert denied_ev["pat_jti"] == pat_jti
|
||||||
|
assert denied_ev["reason"] == "pat_revoked"
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""nova idp setup tests (REQ-340, REQ-341, C-2.1).
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
* ``generate_template()`` produces a valid CFN dict with the expected
|
||||||
|
resource types (3 Lambdas, 4 DDB tables, KMS key, 3 URLs, 3 roles).
|
||||||
|
* ``--check`` (mock AWS) → prints a prerequisite report.
|
||||||
|
* ``--dry-run`` → resource summary.
|
||||||
|
* ``--apply`` (mock cloudformation deploy) → prompts + deploys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
|
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _load(mod_name, rel_path):
|
||||||
|
spec = importlib.util.spec_from_file_location(mod_name, rel_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
_CFN_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_cfn.py"
|
||||||
|
_SETUP_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_setup.py"
|
||||||
|
cfn = _load("nova_idp_cfn_test", _CFN_PATH)
|
||||||
|
setup = _load("nova_idp_setup_test", _SETUP_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# generate_template
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_has_expected_resources():
|
||||||
|
t = cfn.generate_template()
|
||||||
|
res = t["Resources"]
|
||||||
|
types = [r["Type"] for r in res.values()]
|
||||||
|
assert types.count("AWS::Lambda::Function") == 3
|
||||||
|
assert types.count("AWS::DynamoDB::Table") == 4
|
||||||
|
assert types.count("AWS::KMS::Key") == 1
|
||||||
|
assert types.count("AWS::KMS::Alias") == 1
|
||||||
|
assert types.count("AWS::Lambda::Url") == 3
|
||||||
|
assert types.count("AWS::IAM::Role") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_kms_key_spec():
|
||||||
|
t = cfn.generate_template()
|
||||||
|
key = t["Resources"]["NovaOidcSigningKey"]["Properties"]
|
||||||
|
assert key["KeySpec"] == "ECC_NIST_P256"
|
||||||
|
assert key["KeyUsage"] == "SIGN_VERIFY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_jwks_url_auth_none():
|
||||||
|
"""JWKS function URL is AuthType NONE (public, REQ-338)."""
|
||||||
|
t = cfn.generate_template()
|
||||||
|
url = t["Resources"]["NovaIdpJwksUrl"]["Properties"]
|
||||||
|
assert url["AuthType"] == "NONE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_auth_url_iam():
|
||||||
|
t = cfn.generate_template()
|
||||||
|
url = t["Resources"]["NovaIdpAuthUrl"]["Properties"]
|
||||||
|
assert url["AuthType"] == "AWS_IAM"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_public_domain_adds_cloudfront():
|
||||||
|
t = cfn.generate_template(public_jwks_domain="jwks.example.com")
|
||||||
|
types = [r["Type"] for r in t["Resources"].values()]
|
||||||
|
assert "AWS::CloudFront::Distribution" in types
|
||||||
|
assert "AWS::CertificateManager::Certificate" in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_summary():
|
||||||
|
t = cfn.generate_template()
|
||||||
|
s = cfn.resource_summary(t)
|
||||||
|
assert s["AWS::Lambda::Function"] == 3
|
||||||
|
assert s["AWS::DynamoDB::Table"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_prerequisites_returns_report():
|
||||||
|
with mock.patch("subprocess.check_output", side_effect=Exception("no creds")):
|
||||||
|
report = setup.check_prerequisites()
|
||||||
|
assert "aws_creds" in report
|
||||||
|
assert report["aws_creds"] is False
|
||||||
|
assert "missing" in report
|
||||||
|
assert "iam_delta" in report
|
||||||
|
assert "cloudformation:*" in report["iam_delta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_prerequisites_with_creds():
|
||||||
|
fake = json.dumps({"Account": "123456789012", "UserId": "u", "Arn": "arn"})
|
||||||
|
with mock.patch("subprocess.check_output", return_value=fake):
|
||||||
|
report = setup.check_prerequisites()
|
||||||
|
assert report["aws_creds"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --dry-run
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_returns_summary():
|
||||||
|
r = setup.generate_and_deploy(dry_run=True)
|
||||||
|
assert r["deployed"] is False
|
||||||
|
assert "AWS::Lambda::Function" in r["summary"]
|
||||||
|
assert r["summary"]["AWS::Lambda::Function"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --apply (mock cloudformation deploy)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_aborts_without_approval():
|
||||||
|
r = setup.generate_and_deploy(approve_fn=lambda: False)
|
||||||
|
assert r["deployed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_deploys_with_approval():
|
||||||
|
with mock.patch("subprocess.check_call", return_value=0):
|
||||||
|
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||||
|
assert r["deployed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_deploy_failure_returns_not_deployed():
|
||||||
|
with mock.patch("subprocess.check_call", side_effect=RuntimeError("cfn error")):
|
||||||
|
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||||
|
assert r["deployed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --verify
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_roundtrip_passes():
|
||||||
|
r = setup.verify()
|
||||||
|
assert r["passed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI wrapper (nova/idp/setup.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_setup_check(capsys):
|
||||||
|
from nova.idp import setup as cli_setup
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.check = True; args.apply = False; args.verify = False; args.dry_run = False
|
||||||
|
args.public_jwks_domain = None
|
||||||
|
rc = cli_setup.run(args)
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "aws_creds" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_setup_dry_run(capsys):
|
||||||
|
from nova.idp import setup as cli_setup
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.check = False; args.apply = False; args.verify = False; args.dry_run = True
|
||||||
|
args.public_jwks_domain = None
|
||||||
|
rc = cli_setup.run(args)
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "AWS::Lambda::Function" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_setup_verify(capsys):
|
||||||
|
from nova.idp import setup as cli_setup
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.check = False; args.apply = False; args.verify = True; args.dry_run = False
|
||||||
|
args.public_jwks_domain = None
|
||||||
|
rc = cli_setup.run(args)
|
||||||
|
assert rc == 0
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""JWKS endpoint tests (REQ-338, D-230).
|
||||||
|
|
||||||
|
Mocks ``kms.get_public_key`` with a test ECDSA P-256 public key DER →
|
||||||
|
asserts the Lambda returns 200 + the right headers + a valid JWK.
|
||||||
|
Cross-verifies: a JWT signed with the test private key verifies with
|
||||||
|
pyjwt using the JWKS key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||||
|
|
||||||
|
_SOURCE_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_jwks.py"
|
||||||
|
)
|
||||||
|
_spec = importlib.util.spec_from_file_location("nova_idp_jwks", _SOURCE_PATH)
|
||||||
|
jwks = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(jwks)
|
||||||
|
|
||||||
|
import core.kms_signing as kms_signing
|
||||||
|
import jwt as pyjwt
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
|
||||||
|
|
||||||
|
class _MockKms:
|
||||||
|
def __init__(self, pub_der):
|
||||||
|
self._pub_der = pub_der
|
||||||
|
|
||||||
|
def get_public_key(self, KeyId):
|
||||||
|
return {"PublicKey": self._pub_der, "KeyId": KeyId}
|
||||||
|
|
||||||
|
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||||
|
# Provided so sign_jwt works in the cross-verify test.
|
||||||
|
return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_keypair():
|
||||||
|
priv = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
pub = priv.public_key()
|
||||||
|
pub_der = pub.public_bytes(
|
||||||
|
encoding=serialization.Encoding.DER,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
return priv, pub, pub_der
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset():
|
||||||
|
yield
|
||||||
|
kms_signing.set_kms_client_for_testing(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwks_returns_200_and_headers(test_keypair):
|
||||||
|
_priv, _pub, pub_der = test_keypair
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(pub_der))
|
||||||
|
resp = jwks.lambda_handler({}, None)
|
||||||
|
assert resp["statusCode"] == 200
|
||||||
|
headers = resp["headers"]
|
||||||
|
assert headers["Content-Type"] == "application/json"
|
||||||
|
assert headers["Cache-Control"] == "public, max-age=3600"
|
||||||
|
assert headers["Access-Control-Allow-Origin"] == "*"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwks_returns_valid_ec_jwk(test_keypair):
|
||||||
|
_priv, _pub, pub_der = test_keypair
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(pub_der))
|
||||||
|
resp = jwks.lambda_handler({}, None)
|
||||||
|
body = json.loads(resp["body"])
|
||||||
|
assert "keys" in body
|
||||||
|
assert len(body["keys"]) == 1
|
||||||
|
jwk = body["keys"][0]
|
||||||
|
assert jwk["kty"] == "EC"
|
||||||
|
assert jwk["crv"] == "P-256"
|
||||||
|
assert "kid" in jwk
|
||||||
|
assert "x" in jwk and "y" in jwk
|
||||||
|
assert len(jwk["x"]) == 43 # 32 bytes → 43 base64url chars
|
||||||
|
assert len(jwk["y"]) == 43
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwks_cross_verifies_jwt(test_keypair):
|
||||||
|
"""A JWT signed with the test private key verifies with the JWKS key."""
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
# Mock KMS that can both sign (for sign_jwt) and serve the public key.
|
||||||
|
mock_kms = _MockKms(pub_der)
|
||||||
|
mock_kms._priv = priv
|
||||||
|
kms_signing.set_kms_client_for_testing(mock_kms)
|
||||||
|
|
||||||
|
# Sign a JWT via kms_signing.sign_jwt.
|
||||||
|
token = kms_signing.sign_jwt(
|
||||||
|
{"sub": "user-1", "exp": 9999999999, "iat": 1, "jti": "j", "aud": "nova-cli"},
|
||||||
|
key_id="alias/nova-oidc-signing",
|
||||||
|
)
|
||||||
|
# Fetch the JWKS via the Lambda.
|
||||||
|
resp = jwks.lambda_handler({}, None)
|
||||||
|
jwk = json.loads(resp["body"])["keys"][0]
|
||||||
|
# Verify the JWT with pyjwt using the JWK.
|
||||||
|
key = pyjwt.PyJWK(jwk).key
|
||||||
|
decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False})
|
||||||
|
assert decoded["sub"] == "user-1"
|
||||||
|
assert decoded["jti"] == "j"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwks_500_on_kms_error():
|
||||||
|
kms_signing.set_kms_client_for_testing(None)
|
||||||
|
# Force get_jwk to raise by using a broken client.
|
||||||
|
broken = mock.MagicMock()
|
||||||
|
broken.get_public_key.side_effect = RuntimeError("KMS down")
|
||||||
|
kms_signing.set_kms_client_for_testing(broken)
|
||||||
|
resp = jwks.lambda_handler({}, None)
|
||||||
|
assert resp["statusCode"] == 500
|
||||||
@@ -0,0 +1,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"
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""KMS signing tests (REQ-337, C-1.1).
|
||||||
|
|
||||||
|
Tests :func:`core.kms_signing.der_to_raw_ecdsa` with a known DER
|
||||||
|
signature and the full :func:`sign_jwt` round-trip with a mocked KMS
|
||||||
|
client (no real AWS calls — C-1.1 documented as a CI gate in
|
||||||
|
``docs/kms-provisioning.md``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import core.kms_signing as kms_signing
|
||||||
|
from core.kms_signing import der_to_raw_ecdsa, sign_jwt, get_jwk
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
import jwt as pyjwt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test keypair — generated once per session (P-256).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def test_keypair():
|
||||||
|
priv = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
pub = priv.public_key()
|
||||||
|
return priv, pub
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def test_pub_der(test_keypair):
|
||||||
|
_priv, pub = test_keypair
|
||||||
|
return pub.public_bytes(
|
||||||
|
encoding=serialization.Encoding.DER,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _MockKmsSignClient:
|
||||||
|
"""Mock KMS client that signs with a test ECDSA private key (DER)."""
|
||||||
|
|
||||||
|
def __init__(self, priv, pub_der, key_id="alias/nova-oidc-signing"):
|
||||||
|
self._priv = priv
|
||||||
|
self._pub_der = pub_der
|
||||||
|
self._key_id = key_id
|
||||||
|
|
||||||
|
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||||
|
assert SigningAlgorithm == "ECDSA_SHA_256"
|
||||||
|
assert MessageType == "RAW"
|
||||||
|
der = self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))
|
||||||
|
return {"Signature": der, "KeyId": KeyId}
|
||||||
|
|
||||||
|
def get_public_key(self, KeyId):
|
||||||
|
return {"PublicKey": self._pub_der, "KeyId": KeyId}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# der_to_raw_ecdsa — unit test with a known DER signature.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_to_raw_ecdsa_known_vector():
|
||||||
|
# Minimal DER: SEQUENCE { INTEGER r, INTEGER s }.
|
||||||
|
# r=5, s=7 → DER: 30 06 02 01 05 02 01 07
|
||||||
|
der = b"\x30\x06\x02\x01\x05\x02\x01\x07"
|
||||||
|
raw = der_to_raw_ecdsa(der)
|
||||||
|
assert len(raw) == 64 # 32 + 32
|
||||||
|
r = int.from_bytes(raw[:32], "big")
|
||||||
|
s = int.from_bytes(raw[32:], "big")
|
||||||
|
assert r == 5
|
||||||
|
assert s == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_to_raw_ecdsa_real_signature(test_keypair):
|
||||||
|
priv, _ = test_keypair
|
||||||
|
msg = b"test message for der->raw"
|
||||||
|
der = priv.sign(msg, ec.ECDSA(hashes.SHA256()))
|
||||||
|
raw = der_to_raw_ecdsa(der)
|
||||||
|
assert len(raw) == 64
|
||||||
|
# Round-trip: raw → (r, s) should verify against the message.
|
||||||
|
r = int.from_bytes(raw[:32], "big")
|
||||||
|
s = int.from_bytes(raw[32:], "big")
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||||||
|
der2 = encode_dss_signature(r, s)
|
||||||
|
# Verifying with the re-encoded DER proves the raw split is correct.
|
||||||
|
priv.public_key().verify(der2, msg, ec.ECDSA(hashes.SHA256()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_to_raw_ecdsa_rejects_oversized_coord():
|
||||||
|
# r needs 33 bytes (2**256+1) → should raise.
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||||||
|
der = encode_dss_signature(2**256 + 1, 1)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
der_to_raw_ecdsa(der, coord_len=32)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sign_jwt — full round-trip with mock KMS + pyjwt verification.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_jwt_roundtrip_verifies_with_pyjwt(test_keypair, test_pub_der):
|
||||||
|
priv, pub = test_keypair
|
||||||
|
client = _MockKmsSignClient(priv, test_pub_der)
|
||||||
|
kms_signing.set_kms_client_for_testing(client)
|
||||||
|
try:
|
||||||
|
claims = {
|
||||||
|
"sub": "user-1",
|
||||||
|
"aud": "nova-cli",
|
||||||
|
"iss": "nova-idp",
|
||||||
|
"exp": 9999999999,
|
||||||
|
"iat": 1700000000,
|
||||||
|
"jti": "test-jti",
|
||||||
|
"roles": ["developer"],
|
||||||
|
}
|
||||||
|
token = sign_jwt(claims, key_id="alias/nova-oidc-signing")
|
||||||
|
parts = token.split(".")
|
||||||
|
assert len(parts) == 3 # header.payload.signature
|
||||||
|
|
||||||
|
# Verify the header.
|
||||||
|
header = json.loads(base64.urlsafe_b64decode(parts[0] + "=="))
|
||||||
|
assert header["alg"] == "ES256"
|
||||||
|
assert header["typ"] == "JWT"
|
||||||
|
assert header["kid"] == "alias/nova-oidc-signing"
|
||||||
|
|
||||||
|
# Verify the signature with pyjwt using the test public key.
|
||||||
|
pem = pub.public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
).decode("ascii")
|
||||||
|
decoded = pyjwt.decode(token, pem, algorithms=["ES256"], options={"verify_aud": False})
|
||||||
|
assert decoded["sub"] == "user-1"
|
||||||
|
assert decoded["jti"] == "test-jti"
|
||||||
|
assert decoded["roles"] == ["developer"]
|
||||||
|
finally:
|
||||||
|
kms_signing.set_kms_client_for_testing(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_jwk_returns_valid_ec_jwk(test_keypair, test_pub_der):
|
||||||
|
priv, pub = test_keypair
|
||||||
|
client = _MockKmsSignClient(priv, test_pub_der)
|
||||||
|
kms_signing.set_kms_client_for_testing(client)
|
||||||
|
try:
|
||||||
|
jwk = get_jwk(key_id="alias/nova-oidc-signing")
|
||||||
|
assert jwk["kty"] == "EC"
|
||||||
|
assert jwk["crv"] == "P-256"
|
||||||
|
assert jwk["kid"] == "alias/nova-oidc-signing"
|
||||||
|
assert jwk["alg"] == "ES256"
|
||||||
|
# x and y are 32 bytes → 43 base64url chars (no padding).
|
||||||
|
assert len(jwk["x"]) == 43
|
||||||
|
assert len(jwk["y"]) == 43
|
||||||
|
# Cross-verify: a JWT signed with the test private key verifies
|
||||||
|
# with pyjwt using this JWK as the key.
|
||||||
|
client2 = _MockKmsSignClient(priv, test_pub_der)
|
||||||
|
kms_signing.set_kms_client_for_testing(client2)
|
||||||
|
token = sign_jwt({"sub": "x", "exp": 9999999999, "iat": 1, "jti": "j"})
|
||||||
|
key = pyjwt.PyJWK(jwk).key
|
||||||
|
decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False})
|
||||||
|
assert decoded["sub"] == "x"
|
||||||
|
finally:
|
||||||
|
kms_signing.set_kms_client_for_testing(None)
|
||||||
@@ -0,0 +1,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"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user