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 ---
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""ABAC fail-closed test for the token-vend Lambda (C-6.1/C-7.1, INV-17).
|
||||
|
||||
🔴 THIS IS THE MOST IMPORTANT TEST OF THE MILESTONE. It verifies that
|
||||
INV-17 (ABAC fail-closed) is a **runtime guarantee**, not just
|
||||
documentation. The grill's #1 finding was that a naive implementation
|
||||
could fail open (vend a token when the ABAC engine is broken). This
|
||||
test pins the opposite: **every** ABAC failure mode → 403 +
|
||||
``token.vend.denied`` (reason ``abac_eval_failed``). Never fail open.
|
||||
|
||||
Failure modes covered:
|
||||
1. ``KyvernoJsonEngine.is_configured()`` returns ``False`` (kj absent).
|
||||
2. ``evaluate_token_vend_policy()`` raises an exception (kj error,
|
||||
policy parse error, subprocess crash).
|
||||
3. ABAC denies (allowed=False) → 403 reason ``abac_denied``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# moto requires a region; the Lambda's lazy boto3.resource("dynamodb")
|
||||
# picks up AWS_DEFAULT_REGION.
|
||||
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||
os.environ.setdefault("AWS_ACCESS_KEY_ID", "test")
|
||||
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test")
|
||||
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
# Load the token-vend Lambda via importlib (`lambda` is a reserved word).
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_token_vend.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_token_vend", _SOURCE_PATH)
|
||||
tv = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tv)
|
||||
|
||||
# Load nova_idp_auth_cfn table helpers + moto for DDB.
|
||||
import boto3
|
||||
from moto import mock_aws
|
||||
|
||||
|
||||
def _create_pats_table(ddb):
|
||||
ddb.create_table(
|
||||
TableName="nova-pats",
|
||||
KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "jti", "AttributeType": "S"},
|
||||
{"AttributeName": "sub", "AttributeType": "S"},
|
||||
{"AttributeName": "pat_hash", "AttributeType": "S"},
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{"IndexName": "sub-index", "KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||
{"IndexName": "pat_hash-index", "KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_lambda_singletons():
|
||||
"""Reset the Lambda's module-level DynamoDB singleton before each test."""
|
||||
tv._dynamodb = None
|
||||
yield
|
||||
tv._dynamodb = None
|
||||
|
||||
|
||||
def _put_active_pat(ddb, jti="pat-active", sub="user-1", owner="t1", role="developer"):
|
||||
ddb.put_item(
|
||||
TableName="nova-pats",
|
||||
Item={
|
||||
"jti": {"S": jti},
|
||||
"sub": {"S": sub},
|
||||
"pat_hash": {"S": "hash-" + jti},
|
||||
"status": {"S": "active"},
|
||||
"issued_at": {"S": "2026-01-01T00:00:00Z"},
|
||||
"expires_at": {"N": str(int(time.time()) + 3600)},
|
||||
"claims": {"S": json.dumps({"sub": sub, "roles": [role], "owner": owner})},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _make_pat_jwt(jti="pat-active", sub="user-1", role="developer", owner="t1"):
|
||||
"""Build an unsigned-ish JWT (signature irrelevant — decoded without verify)."""
|
||||
import base64
|
||||
header = base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||
payload = base64.urlsafe_b64encode(json.dumps({
|
||||
"jti": jti, "sub": sub, "exp": int(time.time()) + 3600,
|
||||
"iat": int(time.time()), "roles": [role], "owner": owner,
|
||||
"typ": "developer_pat",
|
||||
}).encode()).rstrip(b"=").decode()
|
||||
return f"{header}.{payload}.sig"
|
||||
|
||||
|
||||
def _vend_event(pat_jwt, **extra):
|
||||
body = {"token": pat_jwt, "environment": "dev", "target_resource": {"type": "contract", "id": "c1", "owner": "t1", "environment": "dev"}, "requested_claims": ["sub"]}
|
||||
body.update(extra)
|
||||
return {"body": json.dumps(body)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 🔴 THE CRITICAL TESTS — fail closed on every ABAC failure mode.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_when_kj_not_configured():
|
||||
"""C-6.1: is_configured() == False → 403 + abac_eval_failed. NEVER fail open."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
# Mock the engine so is_configured() returns False (kj absent).
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = False
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine):
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
body = json.loads(resp["body"])
|
||||
assert body["reason"] == "abac_eval_failed"
|
||||
assert body["error"] == "token_vend_denied"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_when_evaluate_raises():
|
||||
"""C-6.1: evaluate() raises → 403 + abac_eval_failed. NEVER fail open."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = True
|
||||
# evaluate_token_vend_policy is called inside _evaluate_abac_fail_closed;
|
||||
# patch the core.abac_evaluator module to raise.
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||
mock.patch("core.abac_evaluator.evaluate_token_vend_policy",
|
||||
side_effect=RuntimeError("kj crashed")):
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
body = json.loads(resp["body"])
|
||||
assert body["reason"] == "abac_eval_failed"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_when_policy_parse_error():
|
||||
"""C-6.1: policy parse error (evaluate raises ValueError) → 403."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = True
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||
mock.patch("core.abac_evaluator.evaluate_token_vend_policy",
|
||||
side_effect=ValueError("policy parse error")):
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "abac_eval_failed"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_when_abac_denies():
|
||||
"""ABAC denies (allowed=False) → 403 + abac_denied (distinct from eval_failed)."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = True
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
|
||||
mock.patch("core.abac_evaluator.evaluate_token_vend_policy",
|
||||
return_value=(False, [], "sha")):
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "abac_denied"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_revoked_pat():
|
||||
"""D-229: revoked PAT → 403 + pat_revoked (before ABAC even runs)."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb, jti="pat-rev")
|
||||
ddb.update_item(
|
||||
TableName="nova-pats",
|
||||
Key={"jti": {"S": "pat-rev"}},
|
||||
UpdateExpression="SET #s = :v",
|
||||
ExpressionAttributeNames={"#s": "status"},
|
||||
ExpressionAttributeValues={":v": {"S": "revoked"}},
|
||||
)
|
||||
pat = _make_pat_jwt(jti="pat-rev")
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "pat_revoked"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_fail_closed_unknown_pat():
|
||||
"""D-229: PAT not in table → 403 + pat_unknown."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
pat = _make_pat_jwt(jti="pat-missing")
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "pat_unknown"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_audit_event_emitted_on_denial(capsys):
|
||||
"""token.vend.denied audit event is emitted on every denial (INV-17)."""
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
pat = _make_pat_jwt()
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.is_configured.return_value = False
|
||||
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine):
|
||||
tv.lambda_handler(_vend_event(pat), None)
|
||||
err = capsys.readouterr().err
|
||||
audit_lines = [l for l in err.strip().split("\n") if l.strip()]
|
||||
denied = [json.loads(l) for l in audit_lines if json.loads(l).get("event") == "token.vend.denied"]
|
||||
assert denied, "expected a token.vend.denied audit event"
|
||||
assert denied[0]["reason"] == "abac_eval_failed"
|
||||
@@ -0,0 +1,195 @@
|
||||
"""E2E token-vend Lambda test (REQ-336, C-6.1) with moto + mock KMS.
|
||||
|
||||
Valid PAT (active) + ABAC allow → KMS-signed OIDC token returned.
|
||||
Revoked PAT → 403. Unknown PAT → 403. ABAC deny → 403. The returned
|
||||
JWT verifies with pyjwt + the mock public key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||
os.environ.setdefault("AWS_ACCESS_KEY_ID", "test")
|
||||
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test")
|
||||
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_token_vend.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_token_vend_e2e", _SOURCE_PATH)
|
||||
tv = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tv)
|
||||
|
||||
import boto3
|
||||
from moto import mock_aws
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
|
||||
import core.kms_signing as kms_signing
|
||||
|
||||
|
||||
def _create_pats_table(ddb):
|
||||
ddb.create_table(
|
||||
TableName="nova-pats",
|
||||
KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "jti", "AttributeType": "S"},
|
||||
{"AttributeName": "sub", "AttributeType": "S"},
|
||||
{"AttributeName": "pat_hash", "AttributeType": "S"},
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{"IndexName": "sub-index", "KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||
{"IndexName": "pat_hash-index", "KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
|
||||
|
||||
def _put_active_pat(ddb, jti="pat-active", sub="user-1", owner="t1", role="developer"):
|
||||
ddb.put_item(
|
||||
TableName="nova-pats",
|
||||
Item={
|
||||
"jti": {"S": jti},
|
||||
"sub": {"S": sub},
|
||||
"pat_hash": {"S": "hash-" + jti},
|
||||
"status": {"S": "active"},
|
||||
"issued_at": {"S": "2026-01-01T00:00:00Z"},
|
||||
"expires_at": {"N": str(int(time.time()) + 3600)},
|
||||
"claims": {"S": json.dumps({"sub": sub, "roles": [role], "owner": owner})},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _make_pat_jwt(jti="pat-active", sub="user-1", role="developer", owner="t1"):
|
||||
import base64
|
||||
header = base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||
payload = base64.urlsafe_b64encode(json.dumps({
|
||||
"jti": jti, "sub": sub, "exp": int(time.time()) + 3600,
|
||||
"iat": int(time.time()), "roles": [role], "owner": owner,
|
||||
"typ": "developer_pat",
|
||||
}).encode()).rstrip(b"=").decode()
|
||||
return f"{header}.{payload}.sig"
|
||||
|
||||
|
||||
def _vend_event(pat_jwt, env="dev", owner="t1"):
|
||||
return {"body": json.dumps({
|
||||
"token": pat_jwt, "environment": env,
|
||||
"target_resource": {"type": "contract", "id": "c1", "owner": owner, "environment": env},
|
||||
"requested_claims": ["sub", "roles"],
|
||||
})}
|
||||
|
||||
|
||||
class _MockKmsSign:
|
||||
def __init__(self, priv, pub_der):
|
||||
self._priv = priv
|
||||
self._pub_der = pub_der
|
||||
|
||||
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||
return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||
|
||||
def get_public_key(self, KeyId):
|
||||
return {"PublicKey": self._pub_der}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_keypair():
|
||||
priv = ec.generate_private_key(ec.SECP256R1())
|
||||
pub = priv.public_key()
|
||||
pub_der = pub.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return priv, pub, pub_der
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset():
|
||||
tv._dynamodb = None
|
||||
yield
|
||||
tv._dynamodb = None
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_valid_pat_abac_allow_vends_token(test_keypair):
|
||||
priv, pub, pub_der = test_keypair
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
kms_signing.set_kms_client_for_testing(_MockKmsSign(priv, pub_der))
|
||||
pat = _make_pat_jwt()
|
||||
# ABAC allow: developer + dev
|
||||
resp = tv.lambda_handler(_vend_event(pat, env="dev", owner="t1"), None)
|
||||
assert resp["statusCode"] == 200, resp["body"]
|
||||
body = json.loads(resp["body"])
|
||||
assert "token" in body
|
||||
assert "expires_at" in body
|
||||
# Verify the JWT with pyjwt + the test public key.
|
||||
pem = pub.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
decoded = pyjwt.decode(body["token"], pem, algorithms=["ES256"], options={"verify_aud": False})
|
||||
assert decoded["sub"] == "user-1"
|
||||
assert decoded["typ"] == "nova_oidc_token"
|
||||
assert decoded["roles"] == ["developer"]
|
||||
assert "jti" in decoded and "iat" in decoded and "exp" in decoded and "iss" in decoded
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_revoked_pat_denied():
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb, jti="pat-r")
|
||||
ddb.update_item(
|
||||
TableName="nova-pats", Key={"jti": {"S": "pat-r"}},
|
||||
UpdateExpression="SET #s = :v",
|
||||
ExpressionAttributeNames={"#s": "status"},
|
||||
ExpressionAttributeValues={":v": {"S": "revoked"}},
|
||||
)
|
||||
pat = _make_pat_jwt(jti="pat-r")
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "pat_revoked"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_unknown_pat_denied():
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
pat = _make_pat_jwt(jti="pat-missing")
|
||||
resp = tv.lambda_handler(_vend_event(pat), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "pat_unknown"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_abac_deny_denied(test_keypair):
|
||||
priv, _pub, pub_der = test_keypair
|
||||
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||
_create_pats_table(ddb)
|
||||
_put_active_pat(ddb)
|
||||
kms_signing.set_kms_client_for_testing(_MockKmsSign(priv, pub_der))
|
||||
pat = _make_pat_jwt()
|
||||
# ABAC deny: developer + prod (developer not allowed in prod)
|
||||
resp = tv.lambda_handler(_vend_event(pat, env="prod", owner="t1"), None)
|
||||
assert resp["statusCode"] == 403
|
||||
assert json.loads(resp["body"])["reason"] == "abac_denied"
|
||||
|
||||
|
||||
@mock_aws
|
||||
def test_missing_token_field():
|
||||
resp = tv.lambda_handler({"body": json.dumps({"environment": "dev"})}, None)
|
||||
assert resp["statusCode"] == 400
|
||||
Reference in New Issue
Block a user