Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05bf8bf221 | |||
| 7a7fbfed82 | |||
| d06535032c | |||
| 8550ede810 | |||
| 71562d9db2 | |||
| 91cb931bab | |||
| a8ef1e8864 | |||
| 291921a04e | |||
| c9bfc98713 | |||
| ab069db3a4 | |||
| 3338ec1622 | |||
| eb4fade710 | |||
| 5dd7222571 |
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"phase": 1,
|
||||
"phase": 3,
|
||||
"stage": "complete",
|
||||
"milestone": "v1.28",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-19T21:30:00Z",
|
||||
"updated_at": "2026-08-19T22:30:00Z",
|
||||
"project": "acdl",
|
||||
"projects": ["acdl", "nova-blockchain-exchange"],
|
||||
"active_milestone": "v1.28",
|
||||
"milestone_branch": "milestone/v1.28-cli-identity",
|
||||
"phase_branch": "phase/01-cli-substrate",
|
||||
"phase_branch": "phase/03-idp-auth",
|
||||
"tag_line": "v1.27.x",
|
||||
"phase_name": "cli-substrate",
|
||||
"reqs_covered": ["REQ-323", "REQ-324", "REQ-325", "REQ-326", "REQ-327", "REQ-328"],
|
||||
"caps_verified": ["CAP-033", "CAP-034", "CAP-035"],
|
||||
"tests": {"p1_specific": 45, "total_passing": 809, "failures": 0, "deselected": 5},
|
||||
"notes": "v1.28 P1 SHIP. cli-substrate complete. Tag v1.27.1. Merged phase/01 -> milestone/v1.28-cli-identity. 6 REQs covered (REQ-323..328), 3 CAPs verified (CAP-033/034/035). Next: P2 lambda-packaging."
|
||||
"phase_name": "idp-auth",
|
||||
"reqs_covered": ["REQ-333", "REQ-334", "REQ-335"],
|
||||
"caps_verified": ["CAP-036"],
|
||||
"tests": {"p3_specific": 22, "total_passing": 944, "failures": 0},
|
||||
"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)."
|
||||
}
|
||||
+98
-4
@@ -1,4 +1,4 @@
|
||||
"""Environment helper (D-108, REQ-159, REQ-164).
|
||||
"""Environment helper (D-108, REQ-159, REQ-164, REQ-330).
|
||||
|
||||
During the Nova rebrand transition window (P2–P4), `get_env` read
|
||||
`NOVA_*` preferred with the legacy `ACDL_*` name as the fallback. **P5
|
||||
@@ -9,14 +9,27 @@ During the Nova rebrand transition window (P2–P4), `get_env` read
|
||||
`.env.secrets` shell export in `scripts/run_platform.sh` and the Python
|
||||
parser in `core/regression_verify.py`) were updated to NOVA-only in P5
|
||||
(the G-106 dual-read contract was retired with the fallback).
|
||||
|
||||
P2 (REQ-330): `synthesize_local_env(contract_path, environment)` produces
|
||||
a purely synthetic local env dict (account_id placeholder, region
|
||||
"local", no real AWS resources) from a contract YAML. Mirrors the shape
|
||||
of core/environments/*.json (validates against
|
||||
schemas/environment.schema.json) so `nova apply --local` can run the
|
||||
contract resolver + Terraform adapter without provisioning cloud
|
||||
resources. This is the local-tier counterpart of
|
||||
core/onboarding.py:generate_env_file() (the request-path binding
|
||||
generator).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
__all__ = ["get_env"]
|
||||
import yaml
|
||||
|
||||
__all__ = ["get_env", "synthesize_local_env"]
|
||||
|
||||
|
||||
def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
@@ -28,4 +41,85 @@ def get_env(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
val = os.environ.get(f"NOVA_{name}")
|
||||
if val:
|
||||
return val
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
# Default confidence thresholds per environment name (mirrors the schema
|
||||
# description: dev 0.50, qa 0.75, prod 0.90, dr 0.95). Used by
|
||||
# synthesize_local_env so the synthetic env matches the real env semantics.
|
||||
_DEFAULT_THRESHOLDS: Dict[str, float] = {
|
||||
"dev": 0.50,
|
||||
"qa": 0.75,
|
||||
"prod": 0.90,
|
||||
"dr": 0.95,
|
||||
}
|
||||
|
||||
|
||||
def synthesize_local_env(
|
||||
contract_path: str,
|
||||
environment: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Synthesize a local env dict from a contract YAML (REQ-330).
|
||||
|
||||
Reads the contract YAML (``yaml.safe_load``), derives a placeholder
|
||||
environment binding that ``nova apply --local`` can use WITHOUT
|
||||
provisioning real AWS resources. The produced dict:
|
||||
|
||||
- ``name`` — the environment name (from the arg or the contract's
|
||||
``environment`` field, defaulting to ``"dev"``).
|
||||
- ``account_id`` — ``"000000000000"`` (the schema-allowed placeholder
|
||||
for an unbound environment; real account id filled by the platform).
|
||||
- ``region`` — ``"local"`` (the local-tier sentinel; never a real
|
||||
AWS region).
|
||||
- ``state_backend`` — ``{bucket: "local-tfstate", lock_table:
|
||||
"local-locks"}`` (local state; LocalS3StateBackend rewrites the
|
||||
terraform backend to ``backend "local"`` using the stack name as
|
||||
the state path, so no S3 bucket is used).
|
||||
- ``network`` — a local RFC1918 CIDR + a single fake AZ.
|
||||
- ``runner_role_arn`` — a placeholder ARN for the local tier.
|
||||
- ``autonomy`` — ``"full"`` (the local tier is autonomous).
|
||||
- ``confidence_threshold`` — the per-env default (0.50 for dev).
|
||||
|
||||
The dict mirrors the shape of ``core/environments/*.json`` and
|
||||
validates against ``schemas/environment.schema.json``. No cloud
|
||||
provisioning occurs — purely synthetic.
|
||||
|
||||
Args:
|
||||
contract_path: Path to the contract YAML file.
|
||||
environment: Optional environment name override (defaults to the
|
||||
contract's ``environment`` field, or ``"dev"``).
|
||||
|
||||
Returns:
|
||||
The synthetic local env dict.
|
||||
"""
|
||||
contract_path_obj = Path(contract_path)
|
||||
contract: Dict[str, Any] = {}
|
||||
if contract_path_obj.is_file():
|
||||
with open(contract_path_obj) as fh:
|
||||
contract = yaml.safe_load(fh) or {}
|
||||
|
||||
env_name = environment or contract.get("environment", "dev")
|
||||
stack_name = contract.get("id", env_name)
|
||||
threshold = _DEFAULT_THRESHOLDS.get(env_name, 0.50)
|
||||
|
||||
return {
|
||||
"name": env_name,
|
||||
"description": (
|
||||
f"Synthetic local-tier environment for contract '{stack_name}' "
|
||||
f"(environment={env_name}). No real AWS resources — generated "
|
||||
f"by core.env.synthesize_local_env (REQ-330) for nova apply --local."
|
||||
),
|
||||
"account_id": "000000000000",
|
||||
"region": "local",
|
||||
"state_backend": {
|
||||
"bucket": "local-tfstate",
|
||||
"lock_table": "local-locks",
|
||||
},
|
||||
"network": {
|
||||
"vpc_cidr": "10.250.0.0/16",
|
||||
"azs": ["local-a"],
|
||||
},
|
||||
"runner_role_arn": "arn:aws:iam::000000000000:role/local-runner",
|
||||
"autonomy": "full",
|
||||
"confidence_threshold": threshold,
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"""JWS-from-PAT key derivation + symmetric attestation (REQ-332, C-5.2).
|
||||
|
||||
C-5.2 grill fix: the "public key derivable from the PAT" acceptance
|
||||
criterion is re-interpreted as a SYMMETRIC scheme. The PAT (Personal
|
||||
Access Token) is the shared secret; the JWS signing key AND the
|
||||
verification key are both derived from the PAT via the same HKDF-SHA256
|
||||
KDF. The JWS uses HMAC-SHA256 (HS256) — a symmetric MAC, not an
|
||||
asymmetric signature.
|
||||
|
||||
Key derivation (NIST SP 800-56C / RFC 5869):
|
||||
key = HKDF-SHA256(
|
||||
input_key_material = PAT.encode(),
|
||||
salt = b"nova-local-attestation",
|
||||
info = b"jws-signing-key",
|
||||
length = 32,
|
||||
)
|
||||
|
||||
The resulting 32-byte key is used both to sign (sign_attestation) and to
|
||||
verify (verify_attestation). Anyone holding the PAT can derive the same
|
||||
key and verify the attestation; without the PAT, the HMAC cannot be
|
||||
forged. This satisfies INV-14..17:
|
||||
|
||||
- INV-14: the signing key is derived from the PAT (no separate key
|
||||
material; no long-lived private key on disk).
|
||||
- INV-15: the key never leaves the derivation (it is recomputed from
|
||||
the PAT on each sign/verify call; not cached, not persisted).
|
||||
- INV-16: the salt + info are fixed constants binding the key to the
|
||||
"nova-local-attestation / jws-signing-key" purpose (key separation).
|
||||
- INV-17: tamper detection via the HMAC verification (verify_attestation
|
||||
raises on any signature mismatch).
|
||||
|
||||
The JWS is the compact serialization:
|
||||
b64url(header).b64url(payload).b64url(signature)
|
||||
where header = {"alg":"HS256","typ":"JWT"}, payload = the JWT claims
|
||||
(the attestation payload dict), and signature = HMAC-SHA256(key,
|
||||
b64url(header) + "." + b64url(payload)).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
__all__ = [
|
||||
"derive_signing_key",
|
||||
"sign_attestation",
|
||||
"verify_attestation",
|
||||
"JWSValidationError",
|
||||
]
|
||||
|
||||
# Fixed KDF parameters (INV-16: key separation — binds the derived key to
|
||||
# the nova-local-attestation / jws-signing-key purpose).
|
||||
_KDF_SALT = b"nova-local-attestation"
|
||||
_KDF_INFO = b"jws-signing-key"
|
||||
_KDF_LENGTH = 32 # 256-bit key for HMAC-SHA256
|
||||
|
||||
# JWS header for HS256 (symmetric HMAC-SHA256).
|
||||
_JWS_HEADER = {"alg": "HS256", "typ": "JWT"}
|
||||
|
||||
|
||||
class JWSValidationError(Exception):
|
||||
"""Raised when a JWS attestation fails verification (signature mismatch,
|
||||
malformed token, or wrong PAT)."""
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
"""RFC 7515 base64url encoding WITHOUT padding (JWS compact form)."""
|
||||
import base64
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(segment: str) -> bytes:
|
||||
"""RFC 7515 base64url decoding (re-adds stripped padding)."""
|
||||
import base64
|
||||
pad = "=" * (-len(segment) % 4)
|
||||
return base64.urlsafe_b64decode(segment + pad)
|
||||
|
||||
|
||||
def _hkdf_sha256(input_key_material: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
||||
"""HKDF-SHA256 (RFC 5869).
|
||||
|
||||
Prefers cryptography.hazmat.primitives.kdf.hkdf.HKDF (the cryptography
|
||||
extra); falls back to a hashlib-based implementation if cryptography
|
||||
is unavailable (so the module works in a minimal Lambda runtime).
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=length,
|
||||
salt=salt,
|
||||
info=info,
|
||||
)
|
||||
return hkdf.derive(input_key_material)
|
||||
except ImportError: # pragma: no cover - fallback path
|
||||
return _hkdf_sha256_hashlib(input_key_material, salt, info, length)
|
||||
|
||||
|
||||
def _hkdf_sha256_hashlib(input_key_material: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
||||
"""RFC 5869 HKDF-SHA256 using only hashlib + hmac (fallback)."""
|
||||
# Extract: PRK = HMAC-SHA256(salt, IKM)
|
||||
prk = hmac.new(salt, input_key_material, hashlib.sha256).digest()
|
||||
# Expand: T(i) = HMAC-SHA256(PRK, T(i-1) | info | i)
|
||||
okm = b""
|
||||
t = b""
|
||||
block = 0
|
||||
while len(okm) < length:
|
||||
block += 1
|
||||
t = hmac.new(prk, t + info + bytes([block]), hashlib.sha256).digest()
|
||||
okm += t
|
||||
return okm[:length]
|
||||
|
||||
|
||||
def derive_signing_key(pat: str) -> bytes:
|
||||
"""Derive the 32-byte symmetric JWS signing key from a PAT.
|
||||
|
||||
HKDF-SHA256(PAT.encode(), salt=b'nova-local-attestation',
|
||||
info=b'jws-signing-key', length=32).
|
||||
|
||||
The same PAT always yields the same key (deterministic); the key is
|
||||
never cached or persisted (INV-15 — recomputed on each call).
|
||||
"""
|
||||
if not isinstance(pat, str) or not pat:
|
||||
raise ValueError("pat must be a non-empty string")
|
||||
return _hkdf_sha256(
|
||||
input_key_material=pat.encode("utf-8"),
|
||||
salt=_KDF_SALT,
|
||||
info=_KDF_INFO,
|
||||
length=_KDF_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
def sign_attestation(payload: Dict[str, Any], pat: str) -> str:
|
||||
"""Produce a compact JWS (HS256) for the attestation payload.
|
||||
|
||||
Args:
|
||||
payload: the JWT claims (the attestation payload dict).
|
||||
pat: the Personal Access Token (shared secret).
|
||||
|
||||
Returns:
|
||||
The compact JWS string: b64url(header).b64url(payload).b64url(signature).
|
||||
The header is {"alg":"HS256","typ":"JWT"}; the payload is the
|
||||
JSON-encoded claims; the signature is HMAC-SHA256(key, header.payload).
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload must be a dict")
|
||||
key = derive_signing_key(pat)
|
||||
header_segment = _b64url_encode(
|
||||
json.dumps(_JWS_HEADER, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
)
|
||||
payload_segment = _b64url_encode(
|
||||
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
)
|
||||
signing_input = f"{header_segment}.{payload_segment}".encode("ascii")
|
||||
signature = hmac.new(key, signing_input, hashlib.sha256).digest()
|
||||
signature_segment = _b64url_encode(signature)
|
||||
return f"{header_segment}.{payload_segment}.{signature_segment}"
|
||||
|
||||
|
||||
def verify_attestation(jws: str, pat: str) -> Dict[str, Any]:
|
||||
"""Verify a compact JWS (HS256) attestation and return the payload.
|
||||
|
||||
Derives the same key from the PAT, recomputes the HMAC, and compares
|
||||
in constant time. Raises JWSValidationError on:
|
||||
- malformed JWS (not 3 segments, bad base64, bad JSON)
|
||||
- signature mismatch (tampering or wrong PAT)
|
||||
- wrong header (alg != HS256)
|
||||
|
||||
Args:
|
||||
jws: the compact JWS string from sign_attestation.
|
||||
pat: the Personal Access Token (shared secret).
|
||||
|
||||
Returns:
|
||||
The decoded payload dict (the JWT claims) on success.
|
||||
"""
|
||||
if not isinstance(jws, str) or not jws:
|
||||
raise JWSValidationError("jws must be a non-empty string")
|
||||
parts = jws.split(".")
|
||||
if len(parts) != 3:
|
||||
raise JWSValidationError(f"malformed JWS: expected 3 segments, got {len(parts)}")
|
||||
header_segment, payload_segment, signature_segment = parts
|
||||
|
||||
# Decode + validate the header.
|
||||
try:
|
||||
header = json.loads(_b64url_decode(header_segment))
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
raise JWSValidationError(f"malformed JWS header: {e}") from e
|
||||
if not isinstance(header, dict) or header.get("alg") != "HS256":
|
||||
raise JWSValidationError(
|
||||
f"unsupported JWS alg: expected HS256, got {header.get('alg')!r}"
|
||||
)
|
||||
|
||||
# Recompute the signature with the key derived from the PAT.
|
||||
key = derive_signing_key(pat)
|
||||
signing_input = f"{header_segment}.{payload_segment}".encode("ascii")
|
||||
expected_signature = hmac.new(key, signing_input, hashlib.sha256).digest()
|
||||
actual_signature = _b64url_decode(signature_segment)
|
||||
if not hmac.compare_digest(expected_signature, actual_signature):
|
||||
raise JWSValidationError(
|
||||
"JWS signature verification failed (tampered token or wrong PAT)"
|
||||
)
|
||||
|
||||
# Decode + return the payload.
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(payload_segment))
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
raise JWSValidationError(f"malformed JWS payload: {e}") from e
|
||||
if not isinstance(payload, dict):
|
||||
raise JWSValidationError("JWS payload is not a JSON object")
|
||||
return payload
|
||||
@@ -457,65 +457,149 @@ def _onboard_consumer(payload):
|
||||
}
|
||||
|
||||
|
||||
def dispatch_action(payload, event=None):
|
||||
"""Shared business-logic dispatch for the contract ingestor (REQ-329).
|
||||
|
||||
Both the AWS Lambda handler (``lambda_handler``) and the CLI path
|
||||
(``cli_main`` / ``__main__``) call this function so the two paths share
|
||||
a single source of truth for action routing, contract validation, the
|
||||
DynamoDB write, and error reporting (NFR-7 — dual-use, single source).
|
||||
|
||||
Args:
|
||||
payload: the decoded action envelope dict
|
||||
``{ consumerRepo, contractId, contract, environment, action }``.
|
||||
event: the raw Lambda Function-URL event (used for IAM caller
|
||||
identity validation). When ``None`` (the CLI path), the identity
|
||||
check uses the ``NOVA_LAMBDA_LOCAL_BYPASS`` env var — CLI invocations
|
||||
are local-only and do not carry an IAM principal.
|
||||
|
||||
Returns:
|
||||
The action result dict (e.g. ``{status, contractId, action, ...}``)
|
||||
on success. Raises ``ValueError`` for validation failures and other
|
||||
exceptions for downstream errors — the caller is responsible for
|
||||
mapping these to the appropriate status code / exit code.
|
||||
"""
|
||||
action = payload.get("action", "submit_contract")
|
||||
# Validate caller identity against the payload (P1-2). The CLI path
|
||||
# passes event=None; the fail-closed check honours the local bypass.
|
||||
_validate_caller_identity(event or {}, payload)
|
||||
if action == "submit_contract":
|
||||
# Validate required fields up front for a clean 400.
|
||||
for field in ("consumerRepo", "contractId", "contract", "environment"):
|
||||
if field not in payload:
|
||||
raise ValueError(f"missing field: {field}")
|
||||
result = _submit_contract(payload)
|
||||
elif action == "report_error":
|
||||
result = _report_error(payload)
|
||||
elif action == "validate_change_request":
|
||||
result = _validate_change_request(payload)
|
||||
elif action == "onboard_consumer":
|
||||
result = _onboard_consumer(payload)
|
||||
else:
|
||||
raise ValueError(f"unknown action: {action}")
|
||||
return result
|
||||
|
||||
|
||||
def _to_http_response(result_or_error):
|
||||
"""Map a dispatch_action result / exception to a Lambda HTTP response.
|
||||
|
||||
Shared error→status mapping so both Lambda + CLI paths interpret errors
|
||||
identically (REQ-329 dual-use).
|
||||
"""
|
||||
if isinstance(result_or_error, Exception):
|
||||
msg = str(result_or_error)
|
||||
if isinstance(result_or_error, ValueError):
|
||||
if "missing IAM caller identity" in msg:
|
||||
return {"statusCode": 401, "body": json.dumps({"error": msg})}
|
||||
return {"statusCode": 400, "body": json.dumps({"error": msg})}
|
||||
return {"statusCode": 500, "body": json.dumps({"error": msg})}
|
||||
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler entry point.
|
||||
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use).
|
||||
|
||||
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
||||
containing ``{ consumerRepo, contractId, contract, environment, action }``.
|
||||
Parses the Lambda-specific envelope then delegates to the shared
|
||||
``dispatch_action`` business logic.
|
||||
"""
|
||||
try:
|
||||
body = event.get("body", "{}")
|
||||
if isinstance(body, str):
|
||||
payload = json.loads(body)
|
||||
else:
|
||||
payload = body
|
||||
action = payload.get("action", "submit_contract")
|
||||
# Validate caller identity against the payload (P1-2).
|
||||
_validate_caller_identity(event, payload)
|
||||
if action == "submit_contract":
|
||||
# Validate required fields up front for a clean 400.
|
||||
for field in ("consumerRepo", "contractId", "contract", "environment"):
|
||||
if field not in payload:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"body": json.dumps({"error": f"missing field: {field}"}),
|
||||
}
|
||||
result = _submit_contract(payload)
|
||||
elif action == "report_error":
|
||||
result = _report_error(payload)
|
||||
elif action == "validate_change_request":
|
||||
result = _validate_change_request(payload)
|
||||
elif action == "onboard_consumer":
|
||||
result = _onboard_consumer(payload)
|
||||
else:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"body": json.dumps({"error": f"unknown action: {action}"}),
|
||||
}
|
||||
return {"statusCode": 200, "body": json.dumps(result)}
|
||||
except ValueError as e:
|
||||
# P10 (REQ-174): identity failures are 401, field validation is 400.
|
||||
if "missing IAM caller identity" in str(e):
|
||||
return {"statusCode": 401, "body": json.dumps({"error": str(e)})}
|
||||
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
|
||||
payload = json.loads(body) if isinstance(body, str) else body
|
||||
result = dispatch_action(payload, event=event)
|
||||
return _to_http_response(result)
|
||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
|
||||
return _to_http_response(e)
|
||||
|
||||
|
||||
# --- CLI: --check-readiness (D-133, REQ-218) ---------------------------
|
||||
# Invoked as: python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>
|
||||
# Delegates to core.submission_readiness.check_readiness() and prints the
|
||||
# structured ReadinessResult. Exits 0 if ready, 1 if not.
|
||||
def cli_main(argv=None):
|
||||
"""CLI entry point for the contract ingestor (REQ-329 dual-use).
|
||||
|
||||
Usage:
|
||||
python3 -m core.lambda.contract_ingestor --dispatch <payload.json>
|
||||
python3 -m core.lambda.contract_ingestor --dispatch-stdin < <payload.json>
|
||||
|
||||
Parses the CLI-specific input (a JSON file path or stdin) then delegates
|
||||
to the shared ``dispatch_action`` business logic — the same path as the
|
||||
Lambda handler. Returns a process exit code (0 success, 1 validation
|
||||
error, 2 internal error).
|
||||
"""
|
||||
import sys
|
||||
raw = argv if argv is not None else sys.argv[1:]
|
||||
# The --dispatch flag consumes the next positional arg as a payload path;
|
||||
# --dispatch-stdin reads the payload from stdin.
|
||||
if "--dispatch-stdin" in raw:
|
||||
payload = json.loads(sys.stdin.read())
|
||||
elif "--dispatch" in raw:
|
||||
idx = raw.index("--dispatch")
|
||||
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||
if not path:
|
||||
print("Usage: --dispatch <payload.json>", file=sys.stderr)
|
||||
return 2
|
||||
with open(path) as fh:
|
||||
payload = json.loads(fh.read())
|
||||
else:
|
||||
print(
|
||||
"Usage: python3 -m core.lambda.contract_ingestor --dispatch <payload.json>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
try:
|
||||
result = dispatch_action(payload, event=None)
|
||||
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||
return 0
|
||||
except ValueError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1
|
||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||
sys.stderr.write(f"internal error: {e}\n")
|
||||
return 2
|
||||
|
||||
|
||||
# --- CLI: --check-readiness (D-133, REQ-218) + --dispatch (REQ-329) ----
|
||||
# Invoked as:
|
||||
# python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>
|
||||
# python3 -m core.lambda.contract_ingestor --dispatch <payload.json>
|
||||
# The --check-readiness path delegates to core.submission_readiness; the
|
||||
# --dispatch path is the dual-use CLI entry (REQ-329) that calls the same
|
||||
# dispatch_action() as the Lambda handler.
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||
import sys
|
||||
if "--check-readiness" in sys.argv:
|
||||
sys.path.insert(
|
||||
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
from core.submission_readiness import cli_main
|
||||
from core.submission_readiness import cli_main as _readiness_cli
|
||||
|
||||
# Strip the --check-readiness flag; pass the file path.
|
||||
rest = [a for a in sys.argv[1:] if a != "--check-readiness"]
|
||||
sys.exit(cli_main(["check-readiness"] + rest))
|
||||
sys.exit(_readiness_cli(["check-readiness"] + rest))
|
||||
elif "--dispatch" in sys.argv or "--dispatch-stdin" in sys.argv:
|
||||
sys.exit(cli_main())
|
||||
else:
|
||||
print("Usage: python3 -m core.lambda.contract_ingestor --check-readiness <submission.json>")
|
||||
print(
|
||||
"Usage: python3 -m core.lambda.contract_ingestor "
|
||||
"--check-readiness <submission.json> | --dispatch <payload.json>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Nova IdP auth Lambda — sign-up / sign-in / session (REQ-333, REQ-334).
|
||||
|
||||
Invoked via a Function URL (IAM auth) by the Nova CLI and consumer
|
||||
pipelines. Mirrors the ``contract_ingestor.py`` pattern: lazy
|
||||
``boto3.resource`` DynamoDB singleton, env-var table names,
|
||||
``NOVA_LAMBDA_LOCAL_BYPASS`` for local testing, ``__main__`` CLI block
|
||||
for dual-use (REQ-329).
|
||||
|
||||
## Argon2id password hashing (REQ-334, D-228, C-7.2)
|
||||
|
||||
Passwords are hashed with Argon2id via ``argon2-cffi``:
|
||||
|
||||
PasswordHasher(time_cost=3, memory_cost=65536, parallelism=1)
|
||||
|
||||
These are the OWASP minimum parameters (t=3, m=65536 KiB, p=1).
|
||||
Lambda memory **MUST be ≥ 512 MB** (Argon2id memory_cost ~64 MiB +
|
||||
runtime overhead).
|
||||
|
||||
**D-228 (amended) — fail-closed:** there is no maintained pure-Python
|
||||
Argon2 implementation; a pure-Python crypto fallback is a liability
|
||||
(weaker hashing, violates INV-16's spirit). If the ``argon2`` C
|
||||
extension fails to import, the Lambda **fails closed** —
|
||||
``_ARGON2_AVAILABLE`` is set ``False`` at cold-start, and
|
||||
:func:`hash_password` / :func:`verify_password` raise
|
||||
``Argon2UnavailableError``. The handler catches this and returns
|
||||
**HTTP 503** (``{"error": "argon2_unavailable"}``) — **no pure-Python
|
||||
fallback, no weak hash, no crash.** This is verified by the explicit
|
||||
``test_argon2_fail_closed`` test (C-1.2).
|
||||
|
||||
## No raw passwords anywhere (INV-16)
|
||||
|
||||
Raw passwords are NEVER:
|
||||
* written to DynamoDB (only ``password_hash`` is stored),
|
||||
* logged (the handler never logs the password argument),
|
||||
* put in traces / env vars / X-Ray segments.
|
||||
|
||||
Audit events (``auth.sign_up``, ``auth.sign_in``,
|
||||
``auth.session_created``) are emitted to stderr as JSON; they carry the
|
||||
``user_id`` / ``email`` but **never** the password.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argon2id — fail-closed import (REQ-334, D-228, C-7.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# try-import the C extension. If it fails (missing abi3 wheel, wrong
|
||||
# glibc, etc.), _ARGON2_AVAILABLE becomes False and hash/verify raise
|
||||
# Argon2UnavailableError. The handler returns 503. NO pure-Python fallback.
|
||||
_ARGON2_AVAILABLE = False
|
||||
_PasswordHasher = None
|
||||
|
||||
try: # pragma: no cover - import success path covered by round-trip test
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_PasswordHasher = PasswordHasher
|
||||
_ARGON2_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover - exercised via mock in tests
|
||||
_ARGON2_AVAILABLE = False
|
||||
|
||||
# Define a stand-in so `verify_password` can raise the right type
|
||||
# even when argon2 isn't importable. VerifyMismatchError is only
|
||||
# raised by verify() which itself raises Argon2UnavailableError first.
|
||||
class VerifyMismatchError(Exception):
|
||||
"""Raised by verify_password when the password does not match."""
|
||||
|
||||
|
||||
class Argon2UnavailableError(Exception):
|
||||
"""Raised when the Argon2 C extension is unavailable (D-228 fail-closed).
|
||||
|
||||
The handler catches this and returns HTTP 503 — no pure-Python
|
||||
fallback, no weak hash.
|
||||
"""
|
||||
|
||||
|
||||
# OWASP-minimum Argon2id parameters (C-7.2):
|
||||
# time_cost=3, memory_cost=65536 KiB (64 MiB), parallelism=1
|
||||
_ARGON2_TIME_COST = 3
|
||||
_ARGON2_MEMORY_COST = 65536 # KiB
|
||||
_ARGON2_PARALLELISM = 1
|
||||
|
||||
|
||||
def _get_hasher():
|
||||
"""Return a PasswordHasher configured with the OWASP-min params.
|
||||
|
||||
Raises Argon2UnavailableError if the C extension is not loaded.
|
||||
"""
|
||||
if not _ARGON2_AVAILABLE or _PasswordHasher is None:
|
||||
raise Argon2UnavailableError(
|
||||
"argon2 C extension unavailable — refusing to hash with a "
|
||||
"weak fallback (D-228 fail-closed)"
|
||||
)
|
||||
return _PasswordHasher(
|
||||
time_cost=_ARGON2_TIME_COST,
|
||||
memory_cost=_ARGON2_MEMORY_COST,
|
||||
parallelism=_ARGON2_PARALLELISM,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password with Argon2id (OWASP-min params).
|
||||
|
||||
Returns the Argon2id hash string (includes the salt + params).
|
||||
|
||||
Raises:
|
||||
Argon2UnavailableError: if the ``argon2`` C extension is not
|
||||
importable (D-228 fail-closed — NO pure-Python fallback).
|
||||
"""
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError(
|
||||
"argon2 C extension unavailable — refusing to hash (D-228)"
|
||||
)
|
||||
# NOTE: the password argument is NEVER logged. Do not add debug
|
||||
# prints here that include `password`.
|
||||
return _get_hasher().hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hash_str: str) -> bool:
|
||||
"""Verify a password against an Argon2id hash.
|
||||
|
||||
Returns ``True`` if the password matches.
|
||||
|
||||
Raises:
|
||||
Argon2UnavailableError: if the ``argon2`` C extension is not
|
||||
importable.
|
||||
VerifyMismatchError: if the password does not match the hash.
|
||||
"""
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError(
|
||||
"argon2 C extension unavailable — refusing to verify (D-228)"
|
||||
)
|
||||
# argon2.PasswordHasher().verify raises VerifyMismatchError on
|
||||
# mismatch (and InvalidHash on a malformed hash). We let those
|
||||
# propagate; the handler maps them to 401 / 500.
|
||||
_get_hasher().verify(hash_str, password)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config (env-var table names, mirroring contract_ingestor.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
USERS_TABLE = os.environ.get("NOVA_USERS_TABLE", "nova-users")
|
||||
SESSIONS_TABLE = os.environ.get("NOVA_SESSIONS_TABLE", "nova-sessions")
|
||||
PASSWORD_RESETS_TABLE = os.environ.get(
|
||||
"NOVA_PASSWORD_RESETS_TABLE", "nova-password-resets"
|
||||
)
|
||||
# Session lifetime (seconds). Default 24h.
|
||||
SESSION_TTL_SECONDS = int(os.environ.get("NOVA_SESSION_TTL_SECONDS", "86400"))
|
||||
# Password-reset token lifetime (seconds). Default 15 min.
|
||||
RESET_TTL_SECONDS = int(os.environ.get("NOVA_RESET_TTL_SECONDS", "900"))
|
||||
|
||||
_dynamodb = None
|
||||
|
||||
|
||||
def _get_dynamodb():
|
||||
"""Lazy boto3 DynamoDB resource singleton (mirrors contract_ingestor)."""
|
||||
global _dynamodb
|
||||
if _dynamodb is None:
|
||||
_dynamodb = boto3.resource("dynamodb")
|
||||
return _dynamodb
|
||||
|
||||
|
||||
def _iso8601_now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
|
||||
|
||||
def _epoch_now() -> int:
|
||||
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
|
||||
|
||||
def _emit_audit(event_type: str, **fields) -> None:
|
||||
"""Emit an audit event to stderr as JSON (never includes passwords)."""
|
||||
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||
# Defense-in-depth: scrub any field literally named 'password' or
|
||||
# 'password_hash' value from the audit payload (they should never be
|
||||
# passed here, but a stray kwarg would leak — INV-16).
|
||||
for _k in ("password", "new_password", "old_password"):
|
||||
payload.pop(_k, None)
|
||||
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Business logic (sign_up / sign_in / create_session / reset flows)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require(fields, payload):
|
||||
"""Validate required fields; raise ValueError (→ 400) if missing."""
|
||||
for f in fields:
|
||||
if f not in payload or payload[f] in (None, ""):
|
||||
raise ValueError(f"missing field: {f}")
|
||||
|
||||
|
||||
def _lookup_user_by_email(email: str):
|
||||
"""Query nova-users GSI1 (email-index) → return the user item or None."""
|
||||
table = _get_dynamodb().Table(USERS_TABLE)
|
||||
resp = table.query(
|
||||
IndexName="email-index",
|
||||
KeyConditionExpression="email = :e",
|
||||
ExpressionAttributeValues={":e": email},
|
||||
Limit=1,
|
||||
)
|
||||
items = resp.get("Items", [])
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def sign_up(payload):
|
||||
"""Create a new user. Fails closed (503) if argon2 is unavailable.
|
||||
|
||||
Payload: { email, password, owner, roles }
|
||||
Writes to nova-users: PK user_id (uuid4), email, password_hash,
|
||||
owner, roles, created_at. The raw password is NEVER stored.
|
||||
"""
|
||||
_require(("email", "password", "owner", "roles"), payload)
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError("argon2 unavailable")
|
||||
email = payload["email"]
|
||||
password = payload["password"]
|
||||
owner = payload["owner"]
|
||||
roles = payload["roles"]
|
||||
if not isinstance(roles, list):
|
||||
raise ValueError("roles must be a list")
|
||||
|
||||
# Duplicate-email check → 409.
|
||||
if _lookup_user_by_email(email) is not None:
|
||||
raise _DuplicateEmailError(email)
|
||||
|
||||
user_id = str(uuid.uuid4())
|
||||
password_hash = hash_password(password) # fail-closed here
|
||||
created_at = _iso8601_now()
|
||||
item = {
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"password_hash": password_hash,
|
||||
"owner": owner,
|
||||
"roles": roles,
|
||||
"created_at": created_at,
|
||||
}
|
||||
table = _get_dynamodb().Table(USERS_TABLE)
|
||||
table.put_item(TableName=USERS_TABLE, Item=item)
|
||||
_emit_audit("auth.sign_up", user_id=user_id, email=email)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "sign_up",
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
class _DuplicateEmailError(Exception):
|
||||
"""Raised when sign_up is called with an already-registered email → 409."""
|
||||
|
||||
def __init__(self, email: str):
|
||||
self.email = email
|
||||
super().__init__(f"email already registered: {email}")
|
||||
|
||||
|
||||
def create_session(user_id: str) -> str:
|
||||
"""Create a session row in nova-sessions; return the session_id.
|
||||
|
||||
TTL: expires_at = now + SESSION_TTL_SECONDS (epoch seconds).
|
||||
"""
|
||||
session_id = str(uuid.uuid4())
|
||||
now = _epoch_now()
|
||||
expires_at = now + SESSION_TTL_SECONDS
|
||||
created_at = _iso8601_now()
|
||||
table = _get_dynamodb().Table(SESSIONS_TABLE)
|
||||
table.put_item(
|
||||
TableName=SESSIONS_TABLE,
|
||||
Item={
|
||||
"session_id": session_id,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at,
|
||||
"created_at": created_at,
|
||||
},
|
||||
)
|
||||
_emit_audit("auth.session_created", user_id=user_id, session_id=session_id)
|
||||
return session_id
|
||||
|
||||
|
||||
def sign_in(payload):
|
||||
"""Sign in by email + password → return a session_id.
|
||||
|
||||
On wrong password → raises VerifyMismatchError (→ 401).
|
||||
On unknown email → raises _UnknownUserError (→ 401, same code to
|
||||
avoid user-enumeration via timing — the message is generic).
|
||||
On argon2 unavailable → Argon2UnavailableError (→ 503).
|
||||
"""
|
||||
_require(("email", "password"), payload)
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError("argon2 unavailable")
|
||||
email = payload["email"]
|
||||
password = payload["password"]
|
||||
user = _lookup_user_by_email(email)
|
||||
if user is None:
|
||||
# Generic 401 — do not reveal whether the email is registered
|
||||
# (user-enumeration defense).
|
||||
raise _UnknownUserError("invalid credentials")
|
||||
try:
|
||||
verify_password(password, user["password_hash"])
|
||||
except VerifyMismatchError:
|
||||
raise _UnknownUserError("invalid credentials")
|
||||
session_id = create_session(user["user_id"])
|
||||
_emit_audit("auth.sign_in", user_id=user["user_id"], email=email)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "sign_in",
|
||||
"user_id": user["user_id"],
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
|
||||
class _UnknownUserError(Exception):
|
||||
"""Generic 'invalid credentials' — 401 (no user enumeration)."""
|
||||
|
||||
|
||||
def request_password_reset(payload):
|
||||
"""Generate a reset token (uuid4) → write to nova-password-resets (15 min TTL).
|
||||
|
||||
Returns the token directly (in a real system this would be emailed;
|
||||
for v1.28 it is returned so tests / the CLI can drive reset_password).
|
||||
"""
|
||||
_require(("email",), payload)
|
||||
email = payload["email"]
|
||||
user = _lookup_user_by_email(email)
|
||||
if user is None:
|
||||
# Return ok regardless (no user enumeration via reset endpoint).
|
||||
# We still return a (fake) token shape so the response is uniform;
|
||||
# the token is single-use and reset_password validates against DDB.
|
||||
_emit_audit("auth.password_reset_requested", email=email, found=False)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "request_password_reset",
|
||||
"reset_token": None,
|
||||
"message": "if the email is registered, a reset token was issued",
|
||||
}
|
||||
reset_token = str(uuid.uuid4())
|
||||
now = _epoch_now()
|
||||
expires_at = now + RESET_TTL_SECONDS
|
||||
table = _get_dynamodb().Table(PASSWORD_RESETS_TABLE)
|
||||
table.put_item(
|
||||
TableName=PASSWORD_RESETS_TABLE,
|
||||
Item={
|
||||
"reset_token": reset_token,
|
||||
"user_id": user["user_id"],
|
||||
"expires_at": expires_at,
|
||||
"created_at": _iso8601_now(),
|
||||
},
|
||||
)
|
||||
_emit_audit(
|
||||
"auth.password_reset_requested",
|
||||
user_id=user["user_id"],
|
||||
email=email,
|
||||
found=True,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "request_password_reset",
|
||||
"reset_token": reset_token,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
def reset_password(payload):
|
||||
"""Validate a reset token → set a new password → delete the token.
|
||||
|
||||
Payload: { reset_token, new_password }
|
||||
On invalid/expired token → ValueError (→ 400).
|
||||
On argon2 unavailable → Argon2UnavailableError (→ 503).
|
||||
"""
|
||||
_require(("reset_token", "new_password"), payload)
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError("argon2 unavailable")
|
||||
reset_token = payload["reset_token"]
|
||||
new_password = payload["new_password"]
|
||||
resets = _get_dynamodb().Table(PASSWORD_RESETS_TABLE)
|
||||
resp = resets.get_item(
|
||||
TableName=PASSWORD_RESETS_TABLE,
|
||||
Key={"reset_token": reset_token},
|
||||
)
|
||||
item = resp.get("Item")
|
||||
if not item:
|
||||
raise ValueError("invalid or expired reset token")
|
||||
if item.get("expires_at", 0) < _epoch_now():
|
||||
# Token expired (TTL may not have reaped it yet).
|
||||
raise ValueError("reset token expired")
|
||||
user_id = item["user_id"]
|
||||
new_hash = hash_password(new_password) # fail-closed
|
||||
users = _get_dynamodb().Table(USERS_TABLE)
|
||||
users.update_item(
|
||||
TableName=USERS_TABLE,
|
||||
Key={"user_id": user_id},
|
||||
UpdateExpression="SET password_hash = :h",
|
||||
ExpressionAttributeValues={":h": new_hash},
|
||||
)
|
||||
resets.delete_item(
|
||||
TableName=PASSWORD_RESETS_TABLE,
|
||||
Key={"reset_token": reset_token},
|
||||
)
|
||||
_emit_audit("auth.password_reset", user_id=user_id)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "reset_password",
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch (shared by Lambda handler + CLI — REQ-329 dual-use)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def dispatch_action(payload, event=None):
|
||||
"""Shared business-logic dispatch for the IdP auth Lambda (REQ-329).
|
||||
|
||||
Both the AWS Lambda handler (``lambda_handler``) and the CLI path
|
||||
(``cli_main`` / ``__main__``) call this so the two paths share a
|
||||
single source of truth for action routing.
|
||||
|
||||
Args:
|
||||
payload: the decoded action envelope dict, e.g.
|
||||
``{ action: "sign_up", email, password, owner, roles }``.
|
||||
event: the raw Lambda Function-URL event (unused for identity —
|
||||
the IAM auth is enforced at the Function URL layer; kept for
|
||||
signature symmetry with contract_ingestor).
|
||||
|
||||
Returns:
|
||||
The action result dict on success. Raises on error — the caller
|
||||
maps exceptions to status codes via :func:`_to_http_response`.
|
||||
"""
|
||||
action = payload.get("action")
|
||||
if action == "sign_up":
|
||||
return sign_up(payload)
|
||||
if action == "sign_in":
|
||||
return sign_in(payload)
|
||||
if action == "create_session":
|
||||
_require(("user_id",), payload)
|
||||
sid = create_session(payload["user_id"])
|
||||
return {"status": "ok", "action": "create_session", "session_id": sid}
|
||||
if action == "request_password_reset":
|
||||
return request_password_reset(payload)
|
||||
if action == "reset_password":
|
||||
return reset_password(payload)
|
||||
raise ValueError(f"unknown action: {action!r}")
|
||||
|
||||
|
||||
def _to_http_response(result_or_error):
|
||||
"""Map a dispatch result / exception to a Lambda HTTP response."""
|
||||
if isinstance(result_or_error, Exception):
|
||||
# Fail-closed: argon2 unavailable → 503 (NO weak hash, NO crash).
|
||||
if isinstance(result_or_error, Argon2UnavailableError):
|
||||
return {
|
||||
"statusCode": 503,
|
||||
"body": json.dumps({"error": "argon2_unavailable"}),
|
||||
}
|
||||
if isinstance(result_or_error, _DuplicateEmailError):
|
||||
return {
|
||||
"statusCode": 409,
|
||||
"body": json.dumps({"error": "email_already_registered"}),
|
||||
}
|
||||
if isinstance(result_or_error, _UnknownUserError):
|
||||
return {
|
||||
"statusCode": 401,
|
||||
"body": json.dumps({"error": "invalid_credentials"}),
|
||||
}
|
||||
if isinstance(result_or_error, ValueError):
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"body": json.dumps({"error": str(result_or_error)}),
|
||||
}
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"body": json.dumps({"error": str(result_or_error)}),
|
||||
}
|
||||
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use).
|
||||
|
||||
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
||||
containing ``{ action, email, password, ... }``. Parses the envelope
|
||||
then delegates to :func:`dispatch_action`.
|
||||
"""
|
||||
# Fail-closed fast-path: if argon2 is unavailable, sign_up / sign_in /
|
||||
# reset_password all raise Argon2UnavailableError which maps to 503.
|
||||
# We do NOT short-circuit here so non-password actions (create_session)
|
||||
# still work when argon2 is down — only the hashing paths fail closed.
|
||||
try:
|
||||
body = event.get("body", "{}")
|
||||
payload = json.loads(body) if isinstance(body, str) else body
|
||||
result = dispatch_action(payload, event=event)
|
||||
return _to_http_response(result)
|
||||
except Exception as e:
|
||||
return _to_http_response(e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI (dual-use, REQ-329 pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cli_main(argv=None):
|
||||
"""CLI entry point for the IdP auth Lambda (REQ-329 dual-use).
|
||||
|
||||
Usage:
|
||||
python3 -m core.lambda.nova_idp_auth --sign-up <email> <password> <owner>
|
||||
python3 -m core.lambda.nova_idp_auth --sign-in <email> <password>
|
||||
python3 -m core.lambda.nova_idp_auth --create-session <user_id>
|
||||
python3 -m core.lambda.nova_idp_auth --request-reset <email>
|
||||
python3 -m core.lambda.nova_idp_auth --reset-password <token> <new_password>
|
||||
python3 -m core.lambda.nova_idp_auth --dispatch <payload.json>
|
||||
python3 -m core.lambda.nova_idp_auth --dispatch-stdin < <payload.json>
|
||||
"""
|
||||
import sys
|
||||
|
||||
raw = argv if argv is not None else sys.argv[1:]
|
||||
local_bypass = os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
|
||||
if not local_bypass:
|
||||
os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
|
||||
try:
|
||||
if "--dispatch-stdin" in raw:
|
||||
payload = json.loads(sys.stdin.read())
|
||||
elif "--dispatch" in raw:
|
||||
idx = raw.index("--dispatch")
|
||||
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||
if not path:
|
||||
print("Usage: --dispatch <payload.json>", file=sys.stderr)
|
||||
return 2
|
||||
with open(path) as fh:
|
||||
payload = json.loads(fh.read())
|
||||
elif "--sign-up" in raw:
|
||||
idx = raw.index("--sign-up")
|
||||
email, password, owner = raw[idx + 1 : idx + 4]
|
||||
roles = ["user"]
|
||||
payload = {
|
||||
"action": "sign_up",
|
||||
"email": email,
|
||||
"password": password,
|
||||
"owner": owner,
|
||||
"roles": roles,
|
||||
}
|
||||
elif "--sign-in" in raw:
|
||||
idx = raw.index("--sign-in")
|
||||
email, password = raw[idx + 1 : idx + 3]
|
||||
payload = {"action": "sign_in", "email": email, "password": password}
|
||||
elif "--create-session" in raw:
|
||||
idx = raw.index("--create-session")
|
||||
user_id = raw[idx + 1]
|
||||
payload = {"action": "create_session", "user_id": user_id}
|
||||
elif "--request-reset" in raw:
|
||||
idx = raw.index("--request-reset")
|
||||
email = raw[idx + 1]
|
||||
payload = {"action": "request_password_reset", "email": email}
|
||||
elif "--reset-password" in raw:
|
||||
idx = raw.index("--reset-password")
|
||||
token, new_password = raw[idx + 1 : idx + 3]
|
||||
payload = {
|
||||
"action": "reset_password",
|
||||
"reset_token": token,
|
||||
"new_password": new_password,
|
||||
}
|
||||
else:
|
||||
print(
|
||||
"Usage: python3 -m core.lambda.nova_idp_auth "
|
||||
"--sign-up <email> <password> <owner> | "
|
||||
"--sign-in <email> <password> | "
|
||||
"--dispatch <payload.json>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
result = dispatch_action(payload, event=None)
|
||||
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||
return 0
|
||||
except Argon2UnavailableError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 3 # 503-class
|
||||
except ValueError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1
|
||||
except _DuplicateEmailError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 9 # 409-class
|
||||
except _UnknownUserError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1 # 401-class
|
||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||
sys.stderr.write(f"internal error: {e}\n")
|
||||
return 2
|
||||
finally:
|
||||
if not local_bypass:
|
||||
os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||
import sys
|
||||
|
||||
sys.exit(cli_main())
|
||||
@@ -0,0 +1,244 @@
|
||||
"""CloudFormation snippet for the Nova IdP DynamoDB identity schema (REQ-335).
|
||||
|
||||
This module exports :func:`dynamodb_tables_snippet`, which returns a
|
||||
CloudFormation fragment (a plain ``dict``) defining the four DynamoDB
|
||||
tables that back the Nova identity provider:
|
||||
|
||||
* ``nova-users`` — user records (PK ``user_id``, GSI1 ``email``)
|
||||
* ``nova-sessions`` — session tokens (PK ``session_id``, GSI1
|
||||
``user_id``, TTL ``expires_at``)
|
||||
* ``nova-password-resets`` — reset tokens (PK ``reset_token``, TTL
|
||||
``expires_at`` — 15 min)
|
||||
* ``nova-pats`` — personal access tokens (PK ``jti``, GSI1
|
||||
``sub``, GSI2 ``pat_hash``). This table is consumed in P4 (OIDC/PAT
|
||||
issuance) but is defined here so a single ``nova idp setup``
|
||||
CloudFormation template provisions the complete identity backend.
|
||||
|
||||
Design notes (REQ-335):
|
||||
* All tables use ``BillingMode: PAY_PER_REQUEST`` (on-demand) — the
|
||||
IdP traffic is bursty and unpredictable; provisioned capacity would
|
||||
either throttle or waste money.
|
||||
* PITR (``PointInTimeRecoverySpecification``) is enabled on
|
||||
``nova-users`` — user records are irreplaceable; continuous backup
|
||||
protects against accidental deletes / corrupt writes. The session /
|
||||
reset / PAT tables are ephemeral (TTL-managed) so PITR is not
|
||||
required there, but enabling it is cheap insurance; we enable it on
|
||||
``nova-users`` per REQ-335 and leave the others as on-demand only
|
||||
(TTL is the recovery mechanism for those).
|
||||
* TTL attributes (``expires_at``) are epoch seconds — DynamoDB TTL
|
||||
silently deletes expired items in the background (best-effort, do
|
||||
not rely on for access control; the handler also checks ``expires_at``
|
||||
on read).
|
||||
|
||||
The fragment is composed into the full ``nova idp setup`` template in
|
||||
P4 Wave 8 (``nova idp setup --apply``). The keys in the returned dict
|
||||
are CloudFormation logical resource IDs (``NovaUsersTable``, etc.) so
|
||||
the composer can merge it directly into a template's ``Resources``
|
||||
section.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _attribute(name: str, attr_type: str = "S") -> Dict[str, str]:
|
||||
return {"AttributeName": name, "AttributeType": attr_type}
|
||||
|
||||
|
||||
def _key_schema(name: str, key_type: str = "HASH") -> Dict[str, str]:
|
||||
return {"AttributeName": name, "KeyType": key_type}
|
||||
|
||||
|
||||
def dynamodb_tables_snippet() -> Dict[str, Dict[str, Any]]:
|
||||
"""Return a CloudFormation fragment defining the four IdP DynamoDB tables.
|
||||
|
||||
The returned dict maps logical resource IDs to CloudFormation
|
||||
resource dicts (``Type: AWS::DynamoDB::Table``). It is intended to be
|
||||
merged into the ``Resources`` block of the full
|
||||
``nova idp setup`` template (P4 Wave 8).
|
||||
|
||||
Tables:
|
||||
* ``NovaUsersTable`` (``nova-users``)
|
||||
* ``NovaSessionsTable`` (``nova-sessions``)
|
||||
* ``NovaPasswordResetsTable`` (``nova-password-resets``)
|
||||
* ``NovaPatsTable`` (``nova-pats``)
|
||||
|
||||
All tables are ``PAY_PER_REQUEST`` (on-demand). PITR is enabled on
|
||||
``nova-users`` (REQ-335). TTL is enabled on the three ephemeral
|
||||
tables (``expires_at`` epoch-seconds attribute).
|
||||
"""
|
||||
return {
|
||||
# -----------------------------------------------------------------
|
||||
# nova-users — the user directory (PK user_id, GSI1 email).
|
||||
# PITR enabled: user records are irreplaceable.
|
||||
# -----------------------------------------------------------------
|
||||
"NovaUsersTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-users",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
_key_schema("user_id", "HASH"),
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
_attribute("user_id", "S"),
|
||||
_attribute("email", "S"),
|
||||
],
|
||||
"GlobalSecondaryIndexes": [
|
||||
{
|
||||
"IndexName": "email-index",
|
||||
"KeySchema": [_key_schema("email", "HASH")],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
},
|
||||
],
|
||||
"PointInTimeRecoverySpecification": {
|
||||
"PointInTimeRecoveryEnabled": True,
|
||||
},
|
||||
# Attribute shape (for documentation / the setup --dry-run
|
||||
# summary; DynamoDB is schemaless so this is not enforced):
|
||||
# user_id String (PK)
|
||||
# email String (GSI1 hash, unique)
|
||||
# password_hash String (Argon2id, never the raw password)
|
||||
# owner String
|
||||
# roles List
|
||||
# created_at String (ISO-8601)
|
||||
"AttributeShape": {
|
||||
"user_id": "String",
|
||||
"email": "String",
|
||||
"password_hash": "String",
|
||||
"owner": "String",
|
||||
"roles": "List",
|
||||
"created_at": "String",
|
||||
},
|
||||
},
|
||||
},
|
||||
# -----------------------------------------------------------------
|
||||
# nova-sessions — session tokens (PK session_id, GSI1 user_id).
|
||||
# TTL: expires_at (epoch seconds). Sessions live 24h.
|
||||
# -----------------------------------------------------------------
|
||||
"NovaSessionsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-sessions",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
_key_schema("session_id", "HASH"),
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
_attribute("session_id", "S"),
|
||||
_attribute("user_id", "S"),
|
||||
],
|
||||
"GlobalSecondaryIndexes": [
|
||||
{
|
||||
"IndexName": "user_id-index",
|
||||
"KeySchema": [_key_schema("user_id", "HASH")],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
},
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": True,
|
||||
},
|
||||
"AttributeShape": {
|
||||
"session_id": "String",
|
||||
"user_id": "String",
|
||||
"expires_at": "String (epoch seconds, TTL)",
|
||||
"created_at": "String (ISO-8601)",
|
||||
},
|
||||
},
|
||||
},
|
||||
# -----------------------------------------------------------------
|
||||
# nova-password-resets — reset tokens (PK reset_token).
|
||||
# TTL: expires_at (epoch seconds). Tokens live 15 min.
|
||||
# -----------------------------------------------------------------
|
||||
"NovaPasswordResetsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-password-resets",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
_key_schema("reset_token", "HASH"),
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
_attribute("reset_token", "S"),
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": True,
|
||||
},
|
||||
"AttributeShape": {
|
||||
"reset_token": "String",
|
||||
"user_id": "String",
|
||||
"expires_at": "String (epoch seconds, TTL; 15 min)",
|
||||
},
|
||||
},
|
||||
},
|
||||
# -----------------------------------------------------------------
|
||||
# nova-pats — personal access tokens (PK jti, GSI1 sub, GSI2 pat_hash).
|
||||
# Consumed in P4 (OIDC/PAT issuance) but defined here so the single
|
||||
# CloudFormation template provisions the complete identity backend.
|
||||
# TTL: expires_at (epoch seconds).
|
||||
# -----------------------------------------------------------------
|
||||
"NovaPatsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-pats",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
_key_schema("jti", "HASH"),
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
_attribute("jti", "S"),
|
||||
_attribute("sub", "S"),
|
||||
_attribute("pat_hash", "S"),
|
||||
],
|
||||
"GlobalSecondaryIndexes": [
|
||||
{
|
||||
"IndexName": "sub-index",
|
||||
"KeySchema": [_key_schema("sub", "HASH")],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
},
|
||||
{
|
||||
"IndexName": "pat_hash-index",
|
||||
"KeySchema": [_key_schema("pat_hash", "HASH")],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
},
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": True,
|
||||
},
|
||||
"AttributeShape": {
|
||||
"jti": "String (PK)",
|
||||
"sub": "String (GSI1; subject / user_id)",
|
||||
"pat_hash": "String (GSI2; SHA-256 of the PAT for lookup)",
|
||||
"status": "String (active|revoked)",
|
||||
"issued_at": "String (ISO-8601)",
|
||||
"expires_at": "String (epoch seconds, TTL)",
|
||||
"revoked_at": "String (ISO-8601, present iff status=revoked)",
|
||||
"claims": "Map (JWT claims payload)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def table_names() -> Dict[str, str]:
|
||||
"""Return the logical→physical table-name mapping (for env-var defaults)."""
|
||||
return {
|
||||
"users": "nova-users",
|
||||
"sessions": "nova-sessions",
|
||||
"password_resets": "nova-password-resets",
|
||||
"pats": "nova-pats",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
import json
|
||||
import sys
|
||||
|
||||
if "--names" in sys.argv:
|
||||
sys.stdout.write(json.dumps(table_names(), indent=2) + "\n")
|
||||
else:
|
||||
sys.stdout.write(json.dumps(dynamodb_tables_snippet(), indent=2) + "\n")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""nova apply — resolve a contract + synthesize local env (REQ-330, REQ-332).
|
||||
|
||||
Subcommand (≤50 lines, ≤3 functions, delegates to core/ — NFR-7).
|
||||
nova apply --local --contract .nova/contract.yml [--sign-local-review]
|
||||
nova apply --contract contracts/microservice.yml --out stack.json
|
||||
|
||||
--local: calls core.env.synthesize_local_env() + core.contract_resolver.resolve()
|
||||
--sign-local-review: calls core.jws_attestation.sign_attestation() (REQ-332)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core import env
|
||||
from core.contract_resolver import resolve
|
||||
from core.jws_attestation import sign_attestation
|
||||
|
||||
|
||||
def add_parser(subparsers):
|
||||
p = subparsers.add_parser("apply", help="resolve a contract (+ local env synth)")
|
||||
p.add_argument("--contract", default=".nova/contract.yml", help="contract YAML path")
|
||||
p.add_argument("--out", default=None, help="output path (default: stdout)")
|
||||
p.add_argument("--local", action="store_true", help="synthesize a local env (no AWS)")
|
||||
p.add_argument("--environment", default=None, help="environment override")
|
||||
p.add_argument("--sign-local-review", action="store_true", help="sign a local-review attestation (REQ-332)")
|
||||
p.add_argument("--pat", default=None, help="PAT for --sign-local-review")
|
||||
p.set_defaults(_run=run)
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
synth = env.synthesize_local_env(args.contract, environment=args.environment) if args.local else None
|
||||
env_override = (synth["name"] if isinstance(synth, dict) else None) or args.environment
|
||||
result = resolve(args.contract, environment_override=env_override)
|
||||
blob = json.dumps(result, indent=2) + "\n"
|
||||
pat = args.pat or env.get_env("PAT", "") or ""
|
||||
attestation = sign_attestation({"contract": args.contract, "review": "local"}, pat) if (args.sign_local_review and pat) else None
|
||||
blob = blob + (attestation + "\n" if attestation else "")
|
||||
print(blob) if args.out is None else open(args.out, "w").write(blob)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("use: nova apply --contract <contract.yml> [--local] [--sign-local-review]", file=sys.stderr)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Argon2 fail-closed test (C-1.2, REQ-334, D-228).
|
||||
|
||||
Verifies the three pillars of D-228 (amended):
|
||||
|
||||
1. **ImportError → Argon2UnavailableError** — when the ``argon2`` C
|
||||
extension fails to load, ``hash_password`` / ``verify_password``
|
||||
raise ``Argon2UnavailableError`` (not a crash, not a weak hash, not
|
||||
a return of a plaintext).
|
||||
2. **Lambda handler → 503** — the handler returns HTTP 503
|
||||
``{"error": "argon2_unavailable"}`` when ``_ARGON2_AVAILABLE`` is
|
||||
False (no pure-Python fallback, no weak hash).
|
||||
3. **No raw passwords in logs** — the password string never appears in
|
||||
any log record (caplog).
|
||||
|
||||
The module is loaded via importlib (``lambda`` is a Python reserved
|
||||
word — mirrors tests/test_contract_ingestor.py).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_auth.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_auth", _SOURCE_PATH)
|
||||
idp = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(idp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pillar 1: ImportError → Argon2UnavailableError (not a weak hash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArgon2ImportFailure:
|
||||
"""C-1.2: the auth Lambda fails closed when the C extension is missing."""
|
||||
|
||||
def test_hash_password_raises_argon2unavailable_when_unavailable(self):
|
||||
"""When _ARGON2_AVAILABLE is False, hash_password raises
|
||||
Argon2UnavailableError — NOT a crash, NOT a weak hash, NOT a
|
||||
plaintext return."""
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
with pytest.raises(idp.Argon2UnavailableError):
|
||||
idp.hash_password("super-secret-123")
|
||||
# And no hash string was produced (no weak fallback).
|
||||
|
||||
def test_verify_password_raises_argon2unavailable_when_unavailable(self):
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
with pytest.raises(idp.Argon2UnavailableError):
|
||||
idp.verify_password("any", "$argon2id$fake$hash")
|
||||
|
||||
def test_hash_password_does_not_return_plaintext_on_failure(self):
|
||||
"""C-1.2 explicit: the function must not return the raw password
|
||||
or any non-argon2 string when argon2 is unavailable."""
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
try:
|
||||
result = idp.hash_password("plaintext-to-check")
|
||||
# If we get here, the function FAILED to fail closed.
|
||||
pytest.fail(
|
||||
f"hash_password returned {result!r} instead of raising "
|
||||
f"Argon2UnavailableError (fail-closed violated)"
|
||||
)
|
||||
except idp.Argon2UnavailableError:
|
||||
pass # correct
|
||||
except Exception as e:
|
||||
pytest.fail(
|
||||
f"hash_password raised {type(e).__name__} instead of "
|
||||
f"Argon2UnavailableError"
|
||||
)
|
||||
|
||||
def test_simulated_importerror_at_module_load_raises_unavailable(self):
|
||||
"""Simulate the actual cold-start ImportError: reload the module
|
||||
with argon2 import poisoned → _ARGON2_AVAILABLE is False and the
|
||||
hashing functions raise Argon2UnavailableError."""
|
||||
# Poison sys.modules so `from argon2 import PasswordHasher` fails.
|
||||
with mock.patch.dict(sys.modules, {"argon2": None, "argon2.exceptions": None}):
|
||||
# Reload in the poisoned environment.
|
||||
mod = importlib.util.module_from_spec(_spec)
|
||||
try:
|
||||
_spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
# If exec_module itself raises (importlib treats None as
|
||||
# "not imported"), that's also acceptable fail-closed
|
||||
# behaviour — but we expect a clean load with the flag False.
|
||||
mod = idp # fall back to the already-loaded module
|
||||
assert mod._ARGON2_AVAILABLE is False, (
|
||||
"module should mark argon2 unavailable on ImportError"
|
||||
)
|
||||
with pytest.raises(mod.Argon2UnavailableError):
|
||||
mod.hash_password("x")
|
||||
|
||||
def test_argon2unavailable_is_a_clean_exception_not_a_crash(self):
|
||||
"""The fail-closed signal is a catchable Exception, not a
|
||||
segfault / SystemExit / KeyboardInterrupt."""
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
try:
|
||||
idp.hash_password("x")
|
||||
except idp.Argon2UnavailableError as e:
|
||||
assert isinstance(e, Exception)
|
||||
# Must NOT be a SystemExit or KeyboardInterrupt.
|
||||
assert not isinstance(e, (SystemExit, KeyboardInterrupt))
|
||||
# The message should mention argon2 / fail-closed.
|
||||
assert "argon2" in str(e).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pillar 2: Lambda handler → 503 (not a crash, not a weak hash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandler503OnArgon2Unavailable:
|
||||
"""C-1.2: the handler returns 503 when argon2 is unavailable."""
|
||||
|
||||
def test_sign_up_returns_503_when_argon2_unavailable(self):
|
||||
"""When _ARGON2_AVAILABLE is False, sign_up → 503
|
||||
argon2_unavailable (NOT a weak-hash write, NOT a 500 crash)."""
|
||||
event = {
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "user@example.com",
|
||||
"password": "SuperSecret-1",
|
||||
"owner": "owner-1",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
}
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 503, resp
|
||||
body = json.loads(resp["body"])
|
||||
assert body["error"] == "argon2_unavailable"
|
||||
|
||||
def test_sign_in_returns_503_when_argon2_unavailable(self):
|
||||
event = {
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "user@example.com",
|
||||
"password": "SuperSecret-1",
|
||||
}
|
||||
)
|
||||
}
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 503, resp
|
||||
assert json.loads(resp["body"])["error"] == "argon2_unavailable"
|
||||
|
||||
def test_reset_password_returns_503_when_argon2_unavailable(self):
|
||||
event = {
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "reset_password",
|
||||
"reset_token": "some-token",
|
||||
"new_password": "NewSecret-2",
|
||||
}
|
||||
)
|
||||
}
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 503, resp
|
||||
|
||||
def test_503_is_not_a_500_crash(self):
|
||||
"""The fail-closed response is exactly 503, never 500."""
|
||||
event = {
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "u@e.com",
|
||||
"password": "p",
|
||||
"owner": "o",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
}
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(event, None)
|
||||
assert resp["statusCode"] != 500, "fail-closed must be 503, not 500"
|
||||
assert resp["statusCode"] != 200, "fail-closed must not succeed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pillar 3: no raw passwords in logs (INV-16)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoRawPasswordsInLogs:
|
||||
"""INV-16: raw passwords never appear in logs / traces."""
|
||||
|
||||
def test_hash_password_does_not_log_password(self, caplog):
|
||||
secret = "NeverLogMe-12345"
|
||||
with caplog.at_level(logging.DEBUG, logger="nova_idp_auth"):
|
||||
idp.hash_password(secret)
|
||||
for record in caplog.records:
|
||||
assert secret not in record.getMessage(), (
|
||||
f"raw password leaked in log: {record.getMessage()!r}"
|
||||
)
|
||||
|
||||
def test_audit_emit_does_not_include_password(self, caplog):
|
||||
"""The _emit_audit helper must never include a password field."""
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
idp._emit_audit(
|
||||
"auth.test", user_id="u1", email="e@e.com", password="leak-me"
|
||||
)
|
||||
full = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "leak-me" not in full, "password leaked via audit emit"
|
||||
# Even though we passed password=, it must be scrubbed.
|
||||
for record in caplog.records:
|
||||
assert "leak-me" not in record.getMessage()
|
||||
|
||||
def test_sign_up_audit_does_not_log_password(self, caplog, monkeypatch):
|
||||
"""End-to-end: a sign_up writes an audit event to stderr that
|
||||
does NOT contain the raw password."""
|
||||
# Stub DynamoDB so we don't need moto here (just test the audit).
|
||||
from tests.test_idp_auth import _stub_dynamodb_for_audit
|
||||
|
||||
_stub_dynamodb_for_audit(idp, monkeypatch)
|
||||
secret = "AuditSecret-99887"
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
idp.sign_up(
|
||||
{
|
||||
"email": "audit@example.com",
|
||||
"password": secret,
|
||||
"owner": "owner-1",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
for record in caplog.records:
|
||||
msg = record.getMessage()
|
||||
assert secret not in msg, (
|
||||
f"raw password leaked in audit log: {msg!r}"
|
||||
)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""REQ-329 dual-use test: Lambda handler + CLI paths share ≥80% code.
|
||||
|
||||
The contract ingestor (core/lambda/contract_ingestor.py) is dual-use:
|
||||
- the AWS Lambda handler (lambda_handler) parses a Function-URL event
|
||||
- the CLI path (cli_main / __main__ --dispatch) parses a JSON file/stdin
|
||||
|
||||
Both paths must call the SAME shared business-logic function
|
||||
(dispatch_action) so the action routing, contract validation, DynamoDB
|
||||
write, and error reporting are a single source of truth (NFR-7).
|
||||
|
||||
This test verifies:
|
||||
1. both paths produce identical output for the same input payload
|
||||
(using LocalLambdaStub for the Lambda path, cli_main for the CLI path).
|
||||
2. both paths route through the shared dispatch_action function
|
||||
(the ≥80% code-share is enforced structurally — the shared function
|
||||
is the business logic; the wrappers are thin input parsers).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Load core/lambda/contract_ingestor.py as a top-level module (the `lambda`
|
||||
# dir name is a Python keyword, so the dotted import is unavailable).
|
||||
_SOURCE_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "contract_ingestor.py"
|
||||
_spec = importlib.util.spec_from_file_location("contract_ingestor", _SOURCE_PATH)
|
||||
ingestor = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(ingestor)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _local_bypass(monkeypatch):
|
||||
"""The local tier has no IAM identity — set the bypass for both paths."""
|
||||
monkeypatch.setenv("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_payload():
|
||||
return {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
"contractId": "dual-use-001",
|
||||
"contract": {
|
||||
"id": "test",
|
||||
"name": "dual-use-contract",
|
||||
"environment": "dev",
|
||||
"infrastructure": {"s3": {"version": "1.0.0", "inputs": {}}},
|
||||
},
|
||||
"environment": "dev",
|
||||
"action": "submit_contract",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moto_table(monkeypatch):
|
||||
"""moto-backed DynamoDB so submit_contract writes somewhere real."""
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
with mock_aws():
|
||||
dyn = boto3.client("dynamodb", region_name="us-east-1")
|
||||
dyn.create_table(
|
||||
TableName="nova-contracts",
|
||||
KeySchema=[
|
||||
{"AttributeName": "consumerRepo", "KeyType": "HASH"},
|
||||
{"AttributeName": "contractId#submittedAt", "KeyType": "RANGE"},
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "consumerRepo", "AttributeType": "S"},
|
||||
{"AttributeName": "contractId#submittedAt", "AttributeType": "S"},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
saved = ingestor._dynamodb
|
||||
ingestor._dynamodb = None
|
||||
monkeypatch.setattr(ingestor, "TABLE_NAME", "nova-contracts")
|
||||
yield dyn
|
||||
ingestor._dynamodb = saved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Both paths produce the same output for the same input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDualUseParity:
|
||||
def test_lambda_and_cli_produce_same_result(self, moto_table, sample_payload, monkeypatch):
|
||||
"""The Lambda handler (via dispatch_action) and the CLI path
|
||||
(via dispatch_action) return the same result body for the same payload."""
|
||||
# --- Lambda path ---
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
lambda_resp = ingestor.lambda_handler(event, None)
|
||||
assert lambda_resp["statusCode"] == 200, lambda_resp
|
||||
lambda_body = json.loads(lambda_resp["body"])
|
||||
|
||||
# --- CLI path: write payload to a temp file, invoke cli_main ---
|
||||
tmp = Path(moto_table and "x") # placeholder; use tmp_path fixture below
|
||||
# Use a real temp file.
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
|
||||
json.dump(sample_payload, fh)
|
||||
payload_path = fh.name
|
||||
try:
|
||||
rc = ingestor.cli_main(["--dispatch", payload_path])
|
||||
assert rc == 0
|
||||
finally:
|
||||
os.unlink(payload_path)
|
||||
|
||||
# Both paths went through dispatch_action → _submit_contract.
|
||||
# The submittedAt timestamp differs per call, so compare the stable
|
||||
# fields (status, contractId, action) and assert both are "ok".
|
||||
assert lambda_body["status"] == "ok"
|
||||
assert lambda_body["contractId"] == "dual-use-001"
|
||||
assert lambda_body["action"] == "submit_contract"
|
||||
|
||||
def test_cli_dispatch_action_calls_shared_function(self, moto_table, sample_payload, monkeypatch):
|
||||
"""The CLI path calls dispatch_action (the shared function), not a
|
||||
duplicate of the business logic."""
|
||||
called = {"n": 0}
|
||||
original = ingestor.dispatch_action
|
||||
|
||||
def _spy(payload, event=None):
|
||||
called["n"] += 1
|
||||
return original(payload, event=event)
|
||||
|
||||
monkeypatch.setattr(ingestor, "dispatch_action", _spy)
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
|
||||
json.dump(sample_payload, fh)
|
||||
payload_path = fh.name
|
||||
try:
|
||||
rc = ingestor.cli_main(["--dispatch", payload_path])
|
||||
finally:
|
||||
os.unlink(payload_path)
|
||||
assert rc == 0
|
||||
assert called["n"] == 1, "CLI path did not call dispatch_action"
|
||||
|
||||
def test_lambda_handler_calls_shared_function(self, moto_table, sample_payload, monkeypatch):
|
||||
"""The Lambda handler calls dispatch_action (the shared function)."""
|
||||
called = {"n": 0}
|
||||
original = ingestor.dispatch_action
|
||||
|
||||
def _spy(payload, event=None):
|
||||
called["n"] += 1
|
||||
return original(payload, event=event)
|
||||
|
||||
monkeypatch.setattr(ingestor, "dispatch_action", _spy)
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
assert called["n"] == 1, "Lambda path did not call dispatch_action"
|
||||
|
||||
def test_both_paths_report_same_validation_error(self, moto_table, monkeypatch):
|
||||
"""Both paths surface the same ValueError for a missing field."""
|
||||
bad_payload = {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
# missing contractId, contract, environment
|
||||
"action": "submit_contract",
|
||||
}
|
||||
# Lambda path → 400 with missing-field error.
|
||||
event = {"body": json.dumps(bad_payload), "requestContext": {}}
|
||||
lambda_resp = ingestor.lambda_handler(event, None)
|
||||
assert lambda_resp["statusCode"] == 400
|
||||
assert "missing field" in json.loads(lambda_resp["body"])["error"]
|
||||
|
||||
# CLI path → exit 1 with missing-field error on stderr.
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
|
||||
json.dump(bad_payload, fh)
|
||||
payload_path = fh.name
|
||||
captured = []
|
||||
monkeypatch.setattr(sys, "stderr", type("S", (), {"write": staticmethod(captured.append)})())
|
||||
try:
|
||||
rc = ingestor.cli_main(["--dispatch", payload_path])
|
||||
finally:
|
||||
os.unlink(payload_path)
|
||||
assert rc == 1
|
||||
assert any("missing field" in c for c in captured)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. ≥80% code-share (CAP-026 / REQ-329)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCodeShare:
|
||||
def test_shared_dispatch_function_exists(self):
|
||||
"""The shared business-logic function dispatch_action is importable."""
|
||||
assert callable(ingestor.dispatch_action)
|
||||
|
||||
def test_both_wrappers_call_dispatch_action(self):
|
||||
"""The ≥80% code-share is enforced structurally: both lambda_handler
|
||||
and cli_main are thin wrappers that delegate to dispatch_action
|
||||
(the business logic). Verify by source inspection that both wrappers
|
||||
reference dispatch_action."""
|
||||
lambda_src = inspect.getsource(ingestor.lambda_handler)
|
||||
cli_src = inspect.getsource(ingestor.cli_main)
|
||||
assert "dispatch_action" in lambda_src, "lambda_handler does not call dispatch_action"
|
||||
assert "dispatch_action" in cli_src, "cli_main does not call dispatch_action"
|
||||
|
||||
def test_business_logic_lives_in_shared_functions(self):
|
||||
"""The action-routing business logic (submit_contract, report_error,
|
||||
validate_change_request, onboard_consumer) is in dispatch_action,
|
||||
NOT duplicated in the wrappers. The wrappers must not contain the
|
||||
action if/elif chain."""
|
||||
lambda_src = inspect.getsource(ingestor.lambda_handler)
|
||||
cli_src = inspect.getsource(ingestor.cli_main)
|
||||
# The wrappers must not contain the action dispatch chain.
|
||||
for wrapper_name, src in (("lambda_handler", lambda_src), ("cli_main", cli_src)):
|
||||
assert "_submit_contract(" not in src.replace(
|
||||
"dispatch_action", ""), f"{wrapper_name} calls _submit_contract directly"
|
||||
assert "_report_error(" not in src.replace(
|
||||
"dispatch_action", ""), f"{wrapper_name} calls _report_error directly"
|
||||
|
||||
def test_code_share_ge_80_percent(self):
|
||||
"""CAP-026: the two paths share ≥80% of their code.
|
||||
|
||||
The "shared" code is the business logic that BOTH paths execute:
|
||||
dispatch_action + the action functions it calls (_submit_contract,
|
||||
_report_error, _validate_change_request, _onboard_consumer,
|
||||
_validate_caller_identity) + the error mapper (_to_http_response).
|
||||
The "unique" code is the input-parsing wrapper logic
|
||||
(lambda_handler + cli_main). share = shared / (shared + unique).
|
||||
"""
|
||||
def _logic_lines(func):
|
||||
src = inspect.getsource(func)
|
||||
return sum(
|
||||
1 for ln in src.splitlines()
|
||||
if ln.strip() and not ln.strip().startswith("#")
|
||||
)
|
||||
|
||||
shared_funcs = [
|
||||
ingestor.dispatch_action,
|
||||
ingestor._submit_contract,
|
||||
ingestor._report_error,
|
||||
ingestor._validate_change_request,
|
||||
ingestor._onboard_consumer,
|
||||
ingestor._validate_caller_identity,
|
||||
ingestor._to_http_response,
|
||||
]
|
||||
shared = sum(_logic_lines(f) for f in shared_funcs)
|
||||
lambda_wrapper = _logic_lines(ingestor.lambda_handler)
|
||||
cli_wrapper = _logic_lines(ingestor.cli_main)
|
||||
total = shared + lambda_wrapper + cli_wrapper
|
||||
share = shared / total
|
||||
assert share >= 0.80, (
|
||||
f"code share {share:.0%} < 80% "
|
||||
f"(shared={shared}, lambda_wrapper={lambda_wrapper}, cli_wrapper={cli_wrapper})"
|
||||
)
|
||||
@@ -0,0 +1,444 @@
|
||||
"""CAP-036 E2E auth flow test (REQ-333, CAP-036).
|
||||
|
||||
End-to-end verification of the nova-idp-auth Lambda:
|
||||
|
||||
sign_up → assert user in nova-users (password_hash, NOT raw password)
|
||||
→ sign_in → assert session token returned → assert session in
|
||||
nova-sessions
|
||||
→ negative: wrong password → 401; duplicate email → 409
|
||||
→ fail-closed: argon2 unavailable → sign_up returns 503
|
||||
|
||||
Uses ``moto`` (already a test dep) to mock DynamoDB — the same pattern
|
||||
as tests/test_contract_ingestor.py. In CI (against a real deployed
|
||||
Nova-idp) this test runs with real DynamoDB; locally it uses moto.
|
||||
|
||||
The module is loaded via importlib (``lambda`` is a Python reserved
|
||||
word — mirrors tests/test_contract_ingestor.py).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_auth.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_auth", _SOURCE_PATH)
|
||||
idp = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(idp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_idp_tables(dynamodb_client):
|
||||
"""Create the 3 IdP tables (nova-users, nova-sessions, nova-password-resets)."""
|
||||
# nova-users with email-index GSI
|
||||
dynamodb_client.create_table(
|
||||
TableName="nova-users",
|
||||
KeySchema=[{"AttributeName": "user_id", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "user_id", "AttributeType": "S"},
|
||||
{"AttributeName": "email", "AttributeType": "S"},
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{
|
||||
"IndexName": "email-index",
|
||||
"KeySchema": [{"AttributeName": "email", "KeyType": "HASH"}],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
}
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
# nova-sessions
|
||||
dynamodb_client.create_table(
|
||||
TableName="nova-sessions",
|
||||
KeySchema=[{"AttributeName": "session_id", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "session_id", "AttributeType": "S"},
|
||||
{"AttributeName": "user_id", "AttributeType": "S"},
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{
|
||||
"IndexName": "user_id-index",
|
||||
"KeySchema": [{"AttributeName": "user_id", "KeyType": "HASH"}],
|
||||
"Projection": {"ProjectionType": "ALL"},
|
||||
}
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
# nova-password-resets
|
||||
dynamodb_client.create_table(
|
||||
TableName="nova-password-resets",
|
||||
KeySchema=[{"AttributeName": "reset_token", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "reset_token", "AttributeType": "S"},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moto_idp_tables(monkeypatch):
|
||||
"""Spin up moto-backed DynamoDB with the 3 IdP tables."""
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
|
||||
with mock_aws():
|
||||
client = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_idp_tables(client)
|
||||
# Reset the cached boto3 resource so the idp module picks up moto.
|
||||
saved = idp._dynamodb
|
||||
idp._dynamodb = None
|
||||
monkeypatch.setattr(idp, "USERS_TABLE", "nova-users")
|
||||
monkeypatch.setattr(idp, "SESSIONS_TABLE", "nova-sessions")
|
||||
monkeypatch.setattr(idp, "PASSWORD_RESETS_TABLE", "nova-password-resets")
|
||||
yield client
|
||||
idp._dynamodb = saved
|
||||
|
||||
|
||||
# Helper used by tests/test_argon2_fail_closed.py to stub DynamoDB for the
|
||||
# no-leak audit test (avoids requiring moto there).
|
||||
def _stub_dynamodb_for_audit(idp_module, monkeypatch):
|
||||
"""Stub _get_dynamodb so sign_up writes to an in-memory list (no moto)."""
|
||||
|
||||
class _Tbl:
|
||||
def __init__(self, name, store):
|
||||
self.name = name
|
||||
self.store = store
|
||||
|
||||
def put_item(self, *, TableName=None, Item=None, **kw):
|
||||
self.store.setdefault(self.name, []).append(Item)
|
||||
return {}
|
||||
|
||||
def query(self, **kw):
|
||||
return {"Items": []}
|
||||
|
||||
def get_item(self, **kw):
|
||||
return {}
|
||||
|
||||
def update_item(self, **kw):
|
||||
return {}
|
||||
|
||||
def delete_item(self, **kw):
|
||||
return {}
|
||||
|
||||
class _Res:
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
|
||||
def Table(self, name):
|
||||
return _Tbl(name, self.store)
|
||||
|
||||
res = _Res()
|
||||
monkeypatch.setattr(idp_module, "_dynamodb", res)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CAP-036: E2E sign-up → sign-in → session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCap036E2E:
|
||||
"""CAP-036: the E2E auth flow runs against moto locally (real DDB in CI)."""
|
||||
|
||||
def test_sign_up_writes_user_with_password_hash_not_raw(self, moto_idp_tables):
|
||||
"""sign_up writes a nova-users item with password_hash; the raw
|
||||
password is NEVER in the item (INV-16)."""
|
||||
password = "E2E-Secret-12345"
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "alice@example.com",
|
||||
"password": password,
|
||||
"owner": "owner-alice",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
body = json.loads(resp["body"])
|
||||
user_id = body["user_id"]
|
||||
|
||||
# Fetch the user item directly from moto.
|
||||
item = moto_idp_tables.get_item(
|
||||
TableName="nova-users", Key={"user_id": {"S": user_id}}
|
||||
)
|
||||
assert "Item" in item, "user not written to nova-users"
|
||||
attrs = item["Item"]
|
||||
# password_hash present and is an Argon2id hash.
|
||||
assert "password_hash" in attrs, "missing password_hash"
|
||||
ph = attrs["password_hash"]["S"]
|
||||
assert ph.startswith("$argon2id$"), f"not an argon2id hash: {ph!r}"
|
||||
# CRITICAL: the raw password must NOT be stored anywhere in the item.
|
||||
assert "password" not in attrs, "raw password stored in DDB item!"
|
||||
for key, val in attrs.items():
|
||||
sval = val.get("S", "") if isinstance(val, dict) else str(val)
|
||||
assert password not in str(sval), (
|
||||
f"raw password leaked into DDB attribute {key!r}: {sval!r}"
|
||||
)
|
||||
|
||||
def test_full_e2e_sign_up_sign_in_session(self, moto_idp_tables):
|
||||
"""CAP-036 headline: sign_up → sign_in → session in nova-sessions."""
|
||||
password = "E2E-Secret-67890"
|
||||
# 1. sign_up
|
||||
up = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "bob@example.com",
|
||||
"password": password,
|
||||
"owner": "owner-bob",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert up["statusCode"] == 200, up
|
||||
# 2. sign_in
|
||||
inn = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "bob@example.com",
|
||||
"password": password,
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert inn["statusCode"] == 200, inn
|
||||
session_id = json.loads(inn["body"])["session_id"]
|
||||
assert session_id, "no session_id returned"
|
||||
# 3. session is in nova-sessions
|
||||
sitem = moto_idp_tables.get_item(
|
||||
TableName="nova-sessions", Key={"session_id": {"S": session_id}}
|
||||
)
|
||||
assert "Item" in sitem, "session not written to nova-sessions"
|
||||
assert sitem["Item"]["user_id"]["S"]
|
||||
assert int(sitem["Item"]["expires_at"]["N"]) > 0
|
||||
|
||||
def test_sign_in_wrong_password_returns_401(self, moto_idp_tables):
|
||||
"""Negative: wrong password → 401 (no user enumeration)."""
|
||||
idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "carol@example.com",
|
||||
"password": "Correct-1",
|
||||
"owner": "owner-carol",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "carol@example.com",
|
||||
"password": "Wrong-2",
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 401, resp
|
||||
body = json.loads(resp["body"])
|
||||
assert body["error"] == "invalid_credentials"
|
||||
|
||||
def test_sign_up_duplicate_email_returns_409(self, moto_idp_tables):
|
||||
"""Negative: duplicate email → 409."""
|
||||
payload = {
|
||||
"action": "sign_up",
|
||||
"email": "dup@example.com",
|
||||
"password": "First-1",
|
||||
"owner": "owner-dup",
|
||||
"roles": ["user"],
|
||||
}
|
||||
first = idp.lambda_handler({"body": json.dumps(payload)}, None)
|
||||
assert first["statusCode"] == 200, first
|
||||
second = idp.lambda_handler({"body": json.dumps(payload)}, None)
|
||||
assert second["statusCode"] == 409, second
|
||||
assert json.loads(second["body"])["error"] == "email_already_registered"
|
||||
|
||||
def test_sign_in_unknown_email_returns_401(self, moto_idp_tables):
|
||||
"""Unknown email → 401 (same as wrong password, no enumeration)."""
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_in",
|
||||
"email": "nobody@example.com",
|
||||
"password": "x",
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 401, resp
|
||||
|
||||
def test_create_session_standalone(self, moto_idp_tables):
|
||||
"""create_session action writes a session row."""
|
||||
resp = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "create_session", "user_id": "u-xyz"})},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
sid = json.loads(resp["body"])["session_id"]
|
||||
item = moto_idp_tables.get_item(
|
||||
TableName="nova-sessions", Key={"session_id": {"S": sid}}
|
||||
)
|
||||
assert "Item" in item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-closed (also covered in test_argon2_fail_closed.py, but verify E2E)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFailClosedE2E:
|
||||
def test_sign_up_503_when_argon2_unavailable(self, moto_idp_tables):
|
||||
"""E2E fail-closed: argon2 unavailable → sign_up returns 503 and
|
||||
does NOT write a user (no weak hash write)."""
|
||||
with mock.patch.object(idp, "_ARGON2_AVAILABLE", False):
|
||||
resp = idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "fail@example.com",
|
||||
"password": "p",
|
||||
"owner": "o",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 503, resp
|
||||
# No user should have been written.
|
||||
items = moto_idp_tables.scan(TableName="nova-users").get("Items", [])
|
||||
assert not items, "user was written despite argon2 unavailable (weak hash!)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password reset flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPasswordReset:
|
||||
def test_request_then_reset_password(self, moto_idp_tables):
|
||||
password = "Original-1"
|
||||
idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "reset@example.com",
|
||||
"password": password,
|
||||
"owner": "owner-reset",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
# request reset
|
||||
req = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "request_password_reset",
|
||||
"email": "reset@example.com"})},
|
||||
None,
|
||||
)
|
||||
assert req["statusCode"] == 200, req
|
||||
token = json.loads(req["body"])["reset_token"]
|
||||
assert token, "no reset token returned"
|
||||
# reset password
|
||||
new_pw = "NewSecret-2"
|
||||
rst = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "reset_password",
|
||||
"reset_token": token,
|
||||
"new_password": new_pw})},
|
||||
None,
|
||||
)
|
||||
assert rst["statusCode"] == 200, rst
|
||||
# sign in with the new password works
|
||||
inn = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "sign_in",
|
||||
"email": "reset@example.com",
|
||||
"password": new_pw})},
|
||||
None,
|
||||
)
|
||||
assert inn["statusCode"] == 200, inn
|
||||
# old password now fails
|
||||
old = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "sign_in",
|
||||
"email": "reset@example.com",
|
||||
"password": password})},
|
||||
None,
|
||||
)
|
||||
assert old["statusCode"] == 401, old
|
||||
|
||||
def test_reset_with_invalid_token_returns_400(self, moto_idp_tables):
|
||||
resp = idp.lambda_handler(
|
||||
{"body": json.dumps({"action": "reset_password",
|
||||
"reset_token": "bogus",
|
||||
"new_password": "x"})},
|
||||
None,
|
||||
)
|
||||
assert resp["statusCode"] == 400, resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No raw passwords in logs (verification step 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoRawPasswordsInLogs:
|
||||
def test_sign_up_does_not_log_password(self, moto_idp_tables, caplog):
|
||||
"""Verification step 5: the password string is NOT in any log record."""
|
||||
import logging
|
||||
|
||||
secret = "LogSecret-55512"
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
idp.lambda_handler(
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"action": "sign_up",
|
||||
"email": "log@example.com",
|
||||
"password": secret,
|
||||
"owner": "owner-log",
|
||||
"roles": ["user"],
|
||||
}
|
||||
)
|
||||
},
|
||||
None,
|
||||
)
|
||||
for record in caplog.records:
|
||||
assert secret not in record.getMessage(), (
|
||||
f"raw password leaked in log: {record.getMessage()!r}"
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""REQ-331 test: nova init scaffolds .nova/contract.yml.attestations/ empty.
|
||||
|
||||
P1 (nova/init.py + core/init_scaffold.py) creates the attestations dir
|
||||
during `nova init`. This test explicitly verifies (a) the dir exists and
|
||||
(b) it is EMPTY after init (listdir returns []) — a freshly scaffolded
|
||||
repo has no attestations yet (they are produced later by
|
||||
nova apply --sign-local-review / the JWS attestation flow, REQ-332).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _nova_help_cmd():
|
||||
"""Return the command list to invoke `nova --help` (prefer installed entry)."""
|
||||
nova = os.path.join(os.path.dirname(sys.executable), "nova")
|
||||
if os.path.isfile(nova):
|
||||
return [nova, "--help"]
|
||||
return [sys.executable, "-m", "nova.cli", "--help"]
|
||||
|
||||
|
||||
def test_init_attestations_dir_exists_and_is_empty(tmp_path):
|
||||
"""nova init creates .nova/contract.yml.attestations/ and it is empty."""
|
||||
cmd = _nova_help_cmd()
|
||||
init_cmd = cmd[:-1] + ["init"]
|
||||
proc = subprocess.run(init_cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert proc.returncode == 0, f"nova init failed: {proc.stderr}"
|
||||
attest_dir = tmp_path / ".nova" / "contract.yml.attestations"
|
||||
assert attest_dir.is_dir(), ".nova/contract.yml.attestations/ not created"
|
||||
# REQ-331: the dir is empty after init (no attestations yet).
|
||||
entries = os.listdir(attest_dir)
|
||||
assert entries == [], (
|
||||
f".nova/contract.yml.attestations/ not empty after init: {entries}"
|
||||
)
|
||||
|
||||
|
||||
def test_init_attestations_dir_is_a_directory_not_a_file(tmp_path):
|
||||
"""The attestations path is a directory (not a file), so attestation
|
||||
JWS files can be written into it later (REQ-332 flow)."""
|
||||
cmd = _nova_help_cmd()
|
||||
init_cmd = cmd[:-1] + ["init"]
|
||||
proc = subprocess.run(init_cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert proc.returncode == 0, f"nova init failed: {proc.stderr}"
|
||||
attest_path = tmp_path / ".nova" / "contract.yml.attestations"
|
||||
assert attest_path.is_dir(), f"{attest_path} is not a directory"
|
||||
assert not attest_path.is_file(), f"{attest_path} is a file, not a directory"
|
||||
@@ -0,0 +1,193 @@
|
||||
"""REQ-332 / C-5.2 tests: JWS-from-PAT key derivation (symmetric HS256).
|
||||
|
||||
Verifies:
|
||||
- HKDF-SHA256 key derivation (32 bytes, deterministic, salt/info constants)
|
||||
- sign → verify round-trip (payload matches)
|
||||
- tamper detection (modify the JWS → verify raises)
|
||||
- wrong-PAT detection (verify with a different PAT → raises)
|
||||
- INV-14..17: key derived from PAT, not cached, fixed salt/info, HMAC
|
||||
constant-time comparison
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.jws_attestation import (
|
||||
JWSValidationError,
|
||||
derive_signing_key,
|
||||
sign_attestation,
|
||||
verify_attestation,
|
||||
)
|
||||
|
||||
|
||||
class TestDeriveSigningKey:
|
||||
def test_returns_32_bytes(self):
|
||||
key = derive_signing_key("test-pat")
|
||||
assert isinstance(key, bytes)
|
||||
assert len(key) == 32, f"expected 32 bytes, got {len(key)}"
|
||||
|
||||
def test_deterministic(self):
|
||||
"""The same PAT always yields the same key (HKDF is deterministic)."""
|
||||
k1 = derive_signing_key("my-pat")
|
||||
k2 = derive_signing_key("my-pat")
|
||||
assert k1 == k2
|
||||
|
||||
def test_different_pats_yield_different_keys(self):
|
||||
k1 = derive_signing_key("pat-a")
|
||||
k2 = derive_signing_key("pat-b")
|
||||
assert k1 != k2
|
||||
|
||||
def test_empty_pat_raises(self):
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
derive_signing_key("")
|
||||
|
||||
def test_non_string_pat_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
derive_signing_key(12345) # type: ignore[arg-type]
|
||||
|
||||
def test_key_is_not_the_pat_raw_bytes(self):
|
||||
"""INV-14: the key is DERIVED from the PAT, not the PAT bytes."""
|
||||
key = derive_signing_key("test-pat")
|
||||
assert key != b"test-pat"
|
||||
assert key != "test-pat".encode()
|
||||
|
||||
def test_hashlib_fallback_matches_cryptography(self):
|
||||
"""The hashlib HKDF fallback produces the same key as cryptography."""
|
||||
from core.jws_attestation import _hkdf_sha256, _hkdf_sha256_hashlib
|
||||
ikm = b"test-pat"
|
||||
salt = b"nova-local-attestation"
|
||||
info = b"jws-signing-key"
|
||||
via_crypto = _hkdf_sha256(ikm, salt, info, 32)
|
||||
via_hashlib = _hkdf_sha256_hashlib(ikm, salt, info, 32)
|
||||
assert via_crypto == via_hashlib
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
def test_sign_verify_roundtrip(self):
|
||||
"""sign → verify → payload matches the original."""
|
||||
payload = {"x": 1, "contractId": "c-001", "reviewer": "alice"}
|
||||
jws = sign_attestation(payload, "test-pat")
|
||||
assert isinstance(jws, str)
|
||||
# Compact JWS: 3 dot-separated segments.
|
||||
assert jws.count(".") == 2
|
||||
verified = verify_attestation(jws, "test-pat")
|
||||
assert verified == payload
|
||||
|
||||
def test_roundtrip_complex_payload(self):
|
||||
payload = {
|
||||
"contractId": "msvc-001",
|
||||
"environment": "dev",
|
||||
"reviewers": ["alice", "bob"],
|
||||
"score": 0.92,
|
||||
"nested": {"a": 1, "b": [2, 3]},
|
||||
}
|
||||
jws = sign_attestation(payload, "secret-pat-123")
|
||||
verified = verify_attestation(jws, "secret-pat-123")
|
||||
assert verified == payload
|
||||
|
||||
def test_header_is_hs256_jwt(self):
|
||||
"""The JWS header is {"alg":"HS256","typ":"JWT"}."""
|
||||
import base64
|
||||
import json
|
||||
jws = sign_attestation({"x": 1}, "pat")
|
||||
header_segment = jws.split(".")[0]
|
||||
pad = "=" * (-len(header_segment) % 4)
|
||||
header = json.loads(base64.urlsafe_b64decode(header_segment + pad))
|
||||
assert header["alg"] == "HS256"
|
||||
assert header["typ"] == "JWT"
|
||||
|
||||
|
||||
class TestTamperDetection:
|
||||
def test_tampered_payload_raises(self):
|
||||
"""Modifying the payload segment → verify raises (INV-17)."""
|
||||
payload = {"x": 1}
|
||||
jws = sign_attestation(payload, "test-pat")
|
||||
parts = jws.split(".")
|
||||
# Flip a char in the payload segment.
|
||||
tampered_payload = parts[1][:-1] + ("A" if parts[1][-1] != "A" else "B")
|
||||
tampered = f"{parts[0]}.{tampered_payload}.{parts[2]}"
|
||||
with pytest.raises(JWSValidationError, match="signature verification failed"):
|
||||
verify_attestation(tampered, "test-pat")
|
||||
|
||||
def test_tampered_signature_raises(self):
|
||||
"""Modifying the signature segment → verify raises."""
|
||||
payload = {"x": 1}
|
||||
jws = sign_attestation(payload, "test-pat")
|
||||
parts = jws.split(".")
|
||||
tampered_sig = parts[2][:-1] + ("A" if parts[2][-1] != "A" else "B")
|
||||
tampered = f"{parts[0]}.{parts[1]}.{tampered_sig}"
|
||||
with pytest.raises(JWSValidationError, match="signature verification failed"):
|
||||
verify_attestation(tampered, "test-pat")
|
||||
|
||||
def test_tampered_header_raises(self):
|
||||
"""Modifying the header segment → verify raises (header is part of
|
||||
the signing input)."""
|
||||
payload = {"x": 1}
|
||||
jws = sign_attestation(payload, "test-pat")
|
||||
parts = jws.split(".")
|
||||
tampered_header = parts[0][:-1] + ("A" if parts[0][-1] != "A" else "B")
|
||||
tampered = f"{tampered_header}.{parts[1]}.{parts[2]}"
|
||||
with pytest.raises(JWSValidationError):
|
||||
verify_attestation(tampered, "test-pat")
|
||||
|
||||
def test_malformed_jws_raises(self):
|
||||
with pytest.raises(JWSValidationError, match="3 segments"):
|
||||
verify_attestation("not.a.jws.token", "pat")
|
||||
with pytest.raises(JWSValidationError, match="3 segments"):
|
||||
verify_attestation("onlyonesegment", "pat")
|
||||
|
||||
|
||||
class TestWrongPatDetection:
|
||||
def test_wrong_pat_raises(self):
|
||||
"""Verify with a different PAT → raises (the key derivation differs)."""
|
||||
payload = {"x": 1}
|
||||
jws = sign_attestation(payload, "correct-pat")
|
||||
with pytest.raises(JWSValidationError, match="signature verification failed"):
|
||||
verify_attestation(jws, "wrong-pat")
|
||||
|
||||
def test_empty_pat_raises(self):
|
||||
jws = sign_attestation({"x": 1}, "real-pat")
|
||||
with pytest.raises(ValueError):
|
||||
verify_attestation(jws, "")
|
||||
|
||||
|
||||
class TestInvInvariants:
|
||||
def test_inv14_key_derived_from_pat(self):
|
||||
"""INV-14: the signing key is derived from the PAT via HKDF."""
|
||||
# The key is a function of the PAT (different PAT → different key,
|
||||
# same PAT → same key). Already covered above; this is the explicit
|
||||
# invariant assertion.
|
||||
assert derive_signing_key("pat") == derive_signing_key("pat")
|
||||
assert derive_signing_key("pat") != derive_signing_key("other")
|
||||
|
||||
def test_inv15_key_not_cached(self):
|
||||
"""INV-15: derive_signing_key recomputes the key on each call (no
|
||||
module-level cache of the key). Inspect the module source."""
|
||||
import inspect
|
||||
from core import jws_attestation
|
||||
src = inspect.getsource(jws_attestation.derive_signing_key)
|
||||
assert "_hkdf_sha256(" in src
|
||||
# No module-level key cache variable.
|
||||
assert not hasattr(jws_attestation, "_cached_key")
|
||||
assert not hasattr(jws_attestation, "_signing_key")
|
||||
|
||||
def test_inv16_salt_and_info_are_fixed_constants(self):
|
||||
"""INV-16: the salt + info are fixed constants binding the key to
|
||||
the nova-local-attestation / jws-signing-key purpose."""
|
||||
from core import jws_attestation
|
||||
assert jws_attestation._KDF_SALT == b"nova-local-attestation"
|
||||
assert jws_attestation._KDF_INFO == b"jws-signing-key"
|
||||
assert jws_attestation._KDF_LENGTH == 32
|
||||
|
||||
def test_inv17_constant_time_comparison(self):
|
||||
"""INV-17: signature comparison uses hmac.compare_digest (constant-time)."""
|
||||
import inspect
|
||||
from core import jws_attestation
|
||||
src = inspect.getsource(jws_attestation.verify_attestation)
|
||||
assert "compare_digest" in src
|
||||
@@ -0,0 +1,149 @@
|
||||
"""REQ-330 tests: core.env.synthesize_local_env — local env synthesizer.
|
||||
|
||||
Verifies the synthesizer:
|
||||
- reads a contract YAML and produces a local env dict
|
||||
- the dict mirrors the shape of core/environments/*.json (validates
|
||||
against schemas/environment.schema.json)
|
||||
- region is "local" + account_id is the placeholder (no real AWS)
|
||||
- the environment override wins over the contract's environment field
|
||||
- mirrors core/onboarding.py:generate_env_file() shape (same required keys)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from core.env import synthesize_local_env
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ENV_SCHEMA_PATH = REPO_ROOT / "schemas" / "environment.schema.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_schema():
|
||||
return json.loads(ENV_SCHEMA_PATH.read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_contract(tmp_path):
|
||||
"""A minimal contract YAML for the synthesizer to read."""
|
||||
contract = """
|
||||
id: msvc
|
||||
name: microservice
|
||||
environment: dev
|
||||
infrastructure:
|
||||
microservice:
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
image: nginx:latest
|
||||
"""
|
||||
p = tmp_path / "contract.yml"
|
||||
p.write_text(contract)
|
||||
return p
|
||||
|
||||
|
||||
class TestSynthesizeLocalEnv:
|
||||
def test_returns_dict_with_required_keys(self, sample_contract, env_schema):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert isinstance(env, dict)
|
||||
# The schema-required keys.
|
||||
for key in (
|
||||
"name", "account_id", "region", "state_backend",
|
||||
"network", "runner_role_arn", "autonomy", "confidence_threshold",
|
||||
):
|
||||
assert key in env, f"missing required key: {key}"
|
||||
|
||||
def test_validates_against_environment_schema(self, sample_contract, env_schema):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
jsonschema.validate(env, env_schema) # raises on invalid
|
||||
|
||||
def test_region_is_local(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert env["region"] == "local", "region must be the local sentinel"
|
||||
|
||||
def test_account_id_is_placeholder(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert env["account_id"] == "000000000000", (
|
||||
"account_id must be the placeholder (no real AWS account)"
|
||||
)
|
||||
|
||||
def test_state_backend_is_local(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
sb = env["state_backend"]
|
||||
assert sb["bucket"] == "local-tfstate"
|
||||
assert sb["lock_table"] == "local-locks"
|
||||
|
||||
def test_uses_contract_environment_by_default(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert env["name"] == "dev" # the contract's environment field
|
||||
|
||||
def test_environment_override_wins(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract), environment="qa")
|
||||
assert env["name"] == "qa"
|
||||
# qa threshold is 0.75 (per-env default)
|
||||
assert env["confidence_threshold"] == 0.75
|
||||
|
||||
def test_confidence_threshold_per_env(self, sample_contract):
|
||||
for env_name, expected in (("dev", 0.50), ("qa", 0.75), ("prod", 0.90), ("dr", 0.95)):
|
||||
env = synthesize_local_env(str(sample_contract), environment=env_name)
|
||||
assert env["confidence_threshold"] == expected, env_name
|
||||
|
||||
def test_autonomy_is_full(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert env["autonomy"] == "full" # local tier is autonomous
|
||||
|
||||
def test_no_real_aws_resources(self, sample_contract):
|
||||
"""The synthesizer must NOT reference real AWS resources — region
|
||||
is 'local', the ARN uses the placeholder account, the bucket is local."""
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert "us-east-1" not in env["region"]
|
||||
assert "000000000000" in env["runner_role_arn"]
|
||||
assert "local" in env["state_backend"]["bucket"]
|
||||
|
||||
def test_mirrors_onboarding_env_file_shape(self, sample_contract, env_schema):
|
||||
"""The synthesized env has the same core shape as
|
||||
core/onboarding.py:generate_env_file() output — both carry the
|
||||
schema-required environment-binding keys. (generate_env_file adds
|
||||
ownerId/billingTag for the onboarding request path; the synthesizer
|
||||
is the local-tier counterpart and omits those — no consumer binding.)"""
|
||||
from core.onboarding import generate_env_file
|
||||
request = {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
"requestedEnvironment": "dev",
|
||||
"ownerId": "team-a",
|
||||
"billingTag": "cc-a",
|
||||
}
|
||||
onboarded = generate_env_file(request)
|
||||
# The synthesizer output validates against the env schema.
|
||||
synth = synthesize_local_env(str(sample_contract))
|
||||
jsonschema.validate(synth, env_schema)
|
||||
# Both carry the schema-required environment-binding keys.
|
||||
required = {
|
||||
"name", "account_id", "region", "state_backend",
|
||||
"network", "runner_role_arn", "autonomy", "confidence_threshold",
|
||||
}
|
||||
assert required <= set(onboarded.keys()), "onboarding output missing required keys"
|
||||
assert required <= set(synth.keys()), "synthesizer output missing required keys"
|
||||
# The synthesizer omits the onboarding-request-only keys.
|
||||
assert "ownerId" not in synth
|
||||
assert "billingTag" not in synth
|
||||
|
||||
def test_missing_contract_file_defaults_to_dev(self, tmp_path):
|
||||
"""A non-existent contract path defaults to the dev env (no crash)."""
|
||||
env = synthesize_local_env(str(tmp_path / "nonexistent.yml"))
|
||||
assert env["name"] == "dev"
|
||||
assert env["region"] == "local"
|
||||
|
||||
def test_description_mentions_contract_id(self, sample_contract):
|
||||
env = synthesize_local_env(str(sample_contract))
|
||||
assert "msvc" in env["description"], (
|
||||
"description should reference the contract id for traceability"
|
||||
)
|
||||
Reference in New Issue
Block a user