cd3418a75e
---ci--- project: acdl phase: 4 milestone: v1.28 status: execute persona: backend-engineer ---
231 lines
9.1 KiB
Python
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" |