"""ABAC fail-closed test for the token-vend Lambda (C-6.1/C-7.1, INV-17). 🔴 THIS IS THE MOST IMPORTANT TEST OF THE MILESTONE. It verifies that INV-17 (ABAC fail-closed) is a **runtime guarantee**, not just documentation. The grill's #1 finding was that a naive implementation could fail open (vend a token when the ABAC engine is broken). This test pins the opposite: **every** ABAC failure mode → 403 + ``token.vend.denied`` (reason ``abac_eval_failed``). Never fail open. Failure modes covered: 1. ``KyvernoJsonEngine.is_configured()`` returns ``False`` (kj absent). 2. ``evaluate_token_vend_policy()`` raises an exception (kj error, policy parse error, subprocess crash). 3. ABAC denies (allowed=False) → 403 reason ``abac_denied``. """ from __future__ import annotations import importlib.util import json import os import sys import time from pathlib import Path from unittest import mock import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # moto requires a region; the Lambda's lazy boto3.resource("dynamodb") # picks up AWS_DEFAULT_REGION. os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1") os.environ.setdefault("AWS_ACCESS_KEY_ID", "test") os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test") os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1") # Load the token-vend Lambda via importlib (`lambda` is a reserved word). _SOURCE_PATH = ( Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_token_vend.py" ) _spec = importlib.util.spec_from_file_location("nova_idp_token_vend", _SOURCE_PATH) tv = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(tv) # Load nova_idp_auth_cfn table helpers + moto for DDB. import boto3 from moto import mock_aws def _create_pats_table(ddb): ddb.create_table( TableName="nova-pats", KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}], AttributeDefinitions=[ {"AttributeName": "jti", "AttributeType": "S"}, {"AttributeName": "sub", "AttributeType": "S"}, {"AttributeName": "pat_hash", "AttributeType": "S"}, ], GlobalSecondaryIndexes=[ {"IndexName": "sub-index", "KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}, {"IndexName": "pat_hash-index", "KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}, ], BillingMode="PAY_PER_REQUEST", ) @pytest.fixture(autouse=True) def _reset_lambda_singletons(): """Reset the Lambda's module-level DynamoDB singleton before each test.""" tv._dynamodb = None yield tv._dynamodb = None def _put_active_pat(ddb, jti="pat-active", sub="user-1", owner="t1", role="developer"): ddb.put_item( TableName="nova-pats", Item={ "jti": {"S": jti}, "sub": {"S": sub}, "pat_hash": {"S": "hash-" + jti}, "status": {"S": "active"}, "issued_at": {"S": "2026-01-01T00:00:00Z"}, "expires_at": {"N": str(int(time.time()) + 3600)}, "claims": {"S": json.dumps({"sub": sub, "roles": [role], "owner": owner})}, }, ) def _make_pat_jwt(jti="pat-active", sub="user-1", role="developer", owner="t1"): """Build an unsigned-ish JWT (signature irrelevant — decoded without verify).""" import base64 header = base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b"=").decode() payload = base64.urlsafe_b64encode(json.dumps({ "jti": jti, "sub": sub, "exp": int(time.time()) + 3600, "iat": int(time.time()), "roles": [role], "owner": owner, "typ": "developer_pat", }).encode()).rstrip(b"=").decode() return f"{header}.{payload}.sig" def _vend_event(pat_jwt, **extra): body = {"token": pat_jwt, "environment": "dev", "target_resource": {"type": "contract", "id": "c1", "owner": "t1", "environment": "dev"}, "requested_claims": ["sub"]} body.update(extra) return {"body": json.dumps(body)} # --------------------------------------------------------------------------- # 🔴 THE CRITICAL TESTS — fail closed on every ABAC failure mode. # --------------------------------------------------------------------------- @mock_aws def test_fail_closed_when_kj_not_configured(): """C-6.1: is_configured() == False → 403 + abac_eval_failed. NEVER fail open.""" ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) _put_active_pat(ddb) pat = _make_pat_jwt() # Mock the engine so is_configured() returns False (kj absent). fake_engine = mock.MagicMock() fake_engine.is_configured.return_value = False with mock.patch("core.policy_engine.get_engine", return_value=fake_engine): resp = tv.lambda_handler(_vend_event(pat), None) assert resp["statusCode"] == 403 body = json.loads(resp["body"]) assert body["reason"] == "abac_eval_failed" assert body["error"] == "token_vend_denied" @mock_aws def test_fail_closed_when_evaluate_raises(): """C-6.1: evaluate() raises → 403 + abac_eval_failed. NEVER fail open.""" ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) _put_active_pat(ddb) pat = _make_pat_jwt() fake_engine = mock.MagicMock() fake_engine.is_configured.return_value = True # evaluate_token_vend_policy is called inside _evaluate_abac_fail_closed; # patch the core.abac_evaluator module to raise. with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \ mock.patch("core.abac_evaluator.evaluate_token_vend_policy", side_effect=RuntimeError("kj crashed")): resp = tv.lambda_handler(_vend_event(pat), None) assert resp["statusCode"] == 403 body = json.loads(resp["body"]) assert body["reason"] == "abac_eval_failed" @mock_aws def test_fail_closed_when_policy_parse_error(): """C-6.1: policy parse error (evaluate raises ValueError) → 403.""" ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) _put_active_pat(ddb) pat = _make_pat_jwt() fake_engine = mock.MagicMock() fake_engine.is_configured.return_value = True with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \ mock.patch("core.abac_evaluator.evaluate_token_vend_policy", side_effect=ValueError("policy parse error")): resp = tv.lambda_handler(_vend_event(pat), None) assert resp["statusCode"] == 403 assert json.loads(resp["body"])["reason"] == "abac_eval_failed" @mock_aws def test_fail_closed_when_abac_denies(): """ABAC denies (allowed=False) → 403 + abac_denied (distinct from eval_failed).""" ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) _put_active_pat(ddb) pat = _make_pat_jwt() fake_engine = mock.MagicMock() fake_engine.is_configured.return_value = True with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \ mock.patch("core.abac_evaluator.evaluate_token_vend_policy", return_value=(False, [], "sha")): resp = tv.lambda_handler(_vend_event(pat), None) assert resp["statusCode"] == 403 assert json.loads(resp["body"])["reason"] == "abac_denied" @mock_aws def test_fail_closed_revoked_pat(): """D-229: revoked PAT → 403 + pat_revoked (before ABAC even runs).""" ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) _put_active_pat(ddb, jti="pat-rev") ddb.update_item( TableName="nova-pats", Key={"jti": {"S": "pat-rev"}}, UpdateExpression="SET #s = :v", ExpressionAttributeNames={"#s": "status"}, ExpressionAttributeValues={":v": {"S": "revoked"}}, ) pat = _make_pat_jwt(jti="pat-rev") resp = tv.lambda_handler(_vend_event(pat), None) assert resp["statusCode"] == 403 assert json.loads(resp["body"])["reason"] == "pat_revoked" @mock_aws def test_fail_closed_unknown_pat(): """D-229: PAT not in table → 403 + pat_unknown.""" ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) pat = _make_pat_jwt(jti="pat-missing") resp = tv.lambda_handler(_vend_event(pat), None) assert resp["statusCode"] == 403 assert json.loads(resp["body"])["reason"] == "pat_unknown" @mock_aws def test_audit_event_emitted_on_denial(capsys): """token.vend.denied audit event is emitted on every denial (INV-17).""" ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) _put_active_pat(ddb) pat = _make_pat_jwt() fake_engine = mock.MagicMock() fake_engine.is_configured.return_value = False with mock.patch("core.policy_engine.get_engine", return_value=fake_engine): tv.lambda_handler(_vend_event(pat), None) err = capsys.readouterr().err audit_lines = [l for l in err.strip().split("\n") if l.strip()] denied = [json.loads(l) for l in audit_lines if json.loads(l).get("event") == "token.vend.denied"] assert denied, "expected a token.vend.denied audit event" assert denied[0]["reason"] == "abac_eval_failed"