Files
acdl/tests/test_abac_fail_closed.py
T
Jon Chery cd3418a75e feat(P04): nova-idp-token-vend Lambda — ABAC fail-closed + KMS sign (REQ-336, C-6.1, backend+security)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: backend-engineer
---
2026-08-19 23:08:06 +00:00

231 lines
9.1 KiB
Python

"""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"