Files
acdl/tests/test_abac_e2e.py
T
CIAgent Orchestrator d247db3569
Nova Slides Render / render (push) Failing after 24s
docs(P01): complete publish-pipeline phase (REQ-354, v1.28.1)
---ci---
project: acdl
phase: 1
milestone: v1.29
status: complete
---/ci---
2026-08-20 05:07:30 +00:00

457 lines
19 KiB
Python

"""ABAC end-to-end test for the token-vend Lambda (Edge 5 item 7, INV-17).
The M1.5 verification-gate spike (PLAN.md Happy Path §3.3 Edge 5 item 7):
Known PAT → known ABAC-allowed action → signed OIDC token → jose/pyjwt
verification → green. Known PAT + ABAC-denied action → 403 with deny
reason logged (INV-17 fail-closed).
This is the end-to-end ABAC path: PAT → revocation check (D-229 strong
read) → kyverno-json ABAC policy evaluation → KMS-signed OIDC token →
JWKS fetch → pyjwt signature verification. It wires the **real**
``core.abac_evaluator.evaluate_token_vend_policy`` (which shells to the
``kj`` binary against ``platform/abac/token-vend.policy``) behind the
token-vend Lambda handler, then verifies the vended OIDC token against
the JWKS the JWKS Lambda would serve — exactly the M1.5 spike shape.
## Two execution surfaces (REQ-362 covered-reference)
* **acdl CI** — ``kj`` is NOT installed (``which kj`` is absent) and
there is no live KMS key. The ABAC-allowed and ABAC-denied tests
therefore ``pytest.skip`` with a clear reason (the ``kj`` binary is a
build-host/nova-platform-ops dep). The fail-closed (policy-absent)
test runs in acdl CI because it does NOT need ``kj`` — it exercises
the ``is_configured()``-False → 403 ``abac_eval_failed`` path.
* **nova-platform-ops CI** — ``kj`` is present at ``/opt/kj/kj`` and the
live KMS key ``alias/nova-oidc-signing`` is reachable. The
ABAC-allowed/denied tests run against the real binary + a mock KMS
(or the live key when marked ``live_aws``).
## Test deps
* ``moto[dynamodb]`` — mocks ``nova-pats`` (revocation strong read).
* mock KMS via ``cryptography`` generated ECDSA P-256 keypair (the same
pattern as ``tests/test_kms_roundtrip.py`` + ``test_pat_revocation.py``).
* ``pyjwt`` — verifies the vended OIDC token against the JWKS the JWKS
Lambda serves (the ``jose``-equivalent verification in the plan; the
repo standardizes on ``pyjwt`` + ``cryptography``, no ``jose`` dep).
"""
from __future__ import annotations
import importlib.util
import json
import os
import shutil
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 Lambdas' 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 three IdP Lambda modules via importlib (`lambda` is a reserved
# word — mirrors tests/test_idp_auth.py / test_pat_revocation.py).
# ---------------------------------------------------------------------------
_TV_PATH = (
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_token_vend.py"
)
_spec_tv = importlib.util.spec_from_file_location("nova_idp_token_vend_e2e", _TV_PATH)
tv = importlib.util.module_from_spec(_spec_tv)
_spec_tv.loader.exec_module(tv)
_JWKS_PATH = (
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_jwks.py"
)
_spec_jwks = importlib.util.spec_from_file_location("nova_idp_jwks_e2e", _JWKS_PATH)
jwks_mod = importlib.util.module_from_spec(_spec_jwks)
_spec_jwks.loader.exec_module(jwks_mod)
import boto3
from moto import mock_aws
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes, serialization
import core.kms_signing as kms_signing
import core.pat_lifecycle as pat_life
# ---------------------------------------------------------------------------
# kj availability — the ABAC-allowed/denied tests invoke the real kj
# binary (nova-platform-ops CI installs it at /opt/kj/kj). In acdl CI kj
# is absent, so those tests skip. The fail-closed (policy-absent) test
# runs without kj (it asserts the is_configured()-False → 403 path).
# ---------------------------------------------------------------------------
KJ_AVAILABLE = shutil.which("kj") is not None
skip_no_kj = pytest.mark.skipif(
not KJ_AVAILABLE,
reason="`kj` binary not on PATH (D-227 build-host dep; runs in "
"nova-platform-ops CI against /opt/kj/kj)",
)
# ---------------------------------------------------------------------------
# Mock KMS (generated ECDSA P-256 keypair) — same pattern as
# tests/test_kms_roundtrip.py and tests/test_pat_revocation.py.
# ---------------------------------------------------------------------------
class _MockKms:
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}
# ---------------------------------------------------------------------------
# DynamoDB fixture — nova-pats (revocation strong read, D-229).
# ---------------------------------------------------------------------------
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_singletons():
"""Reset module-level singletons + the test-injected KMS client
before/after each test (mirrors test_pat_revocation.py)."""
tv._dynamodb = None
pat_life._dynamodb = None
yield
tv._dynamodb = None
pat_life._dynamodb = None
kms_signing.set_kms_client_for_testing(None)
@pytest.fixture
def mock_kms():
"""Install a mock KMS client backed by a generated P-256 keypair."""
priv = ec.generate_private_key(ec.SECP256R1())
pub_der = priv.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
return priv
@pytest.fixture
def moto_pats():
"""Spin up moto-backed DynamoDB with the nova-pats table."""
with mock_aws():
client = boto3.client("dynamodb", region_name="us-east-1")
_create_pats_table(client)
yield client
def _issue_pat(sub="dev-alice", roles=None, owner="owner-alice"):
"""Issue a real PAT (KMS-signed JWT, hash stored in nova-pats) for
the ABAC-allowed scenario — subject.role='developer', owner matches
the target resource owner."""
roles = roles or ["developer"]
return pat_life.issue_pat(sub, roles, owner, ttl_seconds=3600)
def _vend_event(pat, **extra):
"""Build a token-vend Lambda event. Defaults: environment='dev',
target_resource owner inherits from the PAT (owner-matches rule
passes for same-tenant vends), requested_claims non-empty."""
body = {
"token": pat,
"environment": "dev",
"target_resource": {
"type": "contract",
"id": "c-allowed",
"owner": "owner-alice",
"environment": "dev",
},
"requested_claims": ["sub", "roles"],
}
body.update(extra)
return {"body": json.dumps(body)}
# ---------------------------------------------------------------------------
# Edge 5 item 7a — ABAC-allowed path: known PAT → ABAC allow → signed
# OIDC token → jose/pyjwt verification → green.
# ---------------------------------------------------------------------------
@skip_no_kj
def test_abac_allowed_vend_then_verify_oidc(moto_pats, mock_kms, capsys):
"""Edge 5 item 7 (allowed path):
subject.role='developer', environment='dev', target_resource.owner
matches subject.owner, requested_claims non-empty → ABAC policy
allows (all three rules pass: owner-matches, role-env-match,
requested-claims-present) → token-vend KMS-signs an OIDC token →
JWKS Lambda serves the public key → pyjwt verifies the signature.
"""
pat = _issue_pat(sub="dev-alice", owner="owner-alice")
resp = tv.lambda_handler(_vend_event(pat), None)
assert resp["statusCode"] == 200, resp
body = json.loads(resp["body"])
assert "token" in body, "no token vended (ABAC should allow this path)"
oidc_token = body["token"]
# Verify the OIDC token signature against the JWKS the JWKS Lambda
# serves (the jose-equivalent verification — pyjwt + cryptography,
# the repo standard).
import jwt as pyjwt
jwks_resp = jwks_mod.lambda_handler({}, None)
assert jwks_resp["statusCode"] == 200, jwks_resp
jwk = json.loads(jwks_resp["body"])["keys"][0]
assert jwk["kty"] == "EC" and jwk["crv"] == "P-256"
key = pyjwt.PyJWK(jwk).key
decoded = pyjwt.decode(
oidc_token, key, algorithms=["ES256"], audience="nova-cli"
)
# OIDC claims (REQ-336).
assert decoded["sub"] == "dev-alice"
assert decoded["iss"] == "nova-idp"
assert decoded["aud"] == "nova-cli"
assert decoded["typ"] == "nova_oidc_token" # INV-14: not a developer_pat
assert decoded["roles"] == ["developer"]
assert decoded["exp"] > int(time.time())
# Audit: token.vend.allowed emitted with policy_sha.
err = capsys.readouterr().err
audit = [json.loads(l) for l in err.strip().split("\n") if l.strip()]
allowed = [a for a in audit if a.get("event") == "token.vend.allowed"]
assert allowed, "expected a token.vend.allowed audit event"
assert "policy_sha" in allowed[0]
# ---------------------------------------------------------------------------
# Edge 5 item 7b — ABAC-denied path: known PAT + ABAC-denied action →
# 403 with deny reason logged (INV-17 fail-closed).
# ---------------------------------------------------------------------------
@skip_no_kj
def test_abac_denied_returns_403_with_reason(moto_pats, mock_kms, capsys):
"""Edge 5 item 7 (denied path):
subject.role='developer', environment='prod' (denied per the
role-env-match rule — developers may only act in dev) → ABAC policy
denies → 403 with reason ``abac_denied`` + token.vend.denied audit
event. INV-17: the denial is logged, not silent.
"""
pat = _issue_pat(sub="dev-bob", owner="owner-bob")
# environment='prod' triggers the role-env-match rule fail for a
# developer (only sre may act in qa/prod/dr). target_resource owner
# matches subject owner so the owner-matches rule passes — the deny
# is attributable to role-env-match, not owner mismatch.
event = _vend_event(
pat,
environment="prod",
target_resource={
"type": "contract",
"id": "c-prod",
"owner": "owner-bob",
"environment": "prod",
},
)
resp = tv.lambda_handler(event, None)
assert resp["statusCode"] == 403, resp
body = json.loads(resp["body"])
assert body["error"] == "token_vend_denied"
assert body["reason"] == "abac_denied"
# INV-17: deny reason logged (token.vend.denied audit event).
err = capsys.readouterr().err
audit = [json.loads(l) for l in err.strip().split("\n") if l.strip()]
denied = [a for a in audit if a.get("event") == "token.vend.denied"]
assert denied, "expected a token.vend.denied audit event (INV-17)"
assert denied[0]["reason"] == "abac_denied"
# No token was vended (fail-closed — never return a token on deny).
assert "token" not in body
# ---------------------------------------------------------------------------
# INV-17 fail-closed — policy file absent → token-vend refuses to sign.
#
# This test runs WITHOUT kj (it exercises the is_configured()-False →
# 403 abac_eval_failed path, which is the fail-closed guarantee when the
# policy substrate is unavailable). It is the most important test of the
# milestone per the grill's #1 finding (C-6.1/C-7.1).
# ---------------------------------------------------------------------------
def test_fail_closed_when_policy_file_absent(moto_pats, mock_kms, capsys):
"""INV-17 (ABAC fail-closed): when the ABAC policy substrate is
unavailable (here: ``kj`` not configured → ``is_configured()`` False),
the token-vend handler refuses to sign — 403 ``abac_eval_failed``,
never fail open.
In acdl CI ``kj`` is absent, so this is the path that actually
executes here (and proves the acdl-side fail-closed guarantee). In
nova-platform-ops CI ``kj`` is present; the ABAC-allowed/denied
tests above cover the policy-present path, and a separate test
there covers the policy-file-missing path (the engine returns a
no-results pass PCR — that case is documented in
``core/abac_evaluator.py`` and mitigated by the caller's
is_configured() guard).
"""
pat = _issue_pat(sub="dev-carol", owner="owner-carol")
# No mocking of the engine needed: the REAL KyvernoJsonEngine is
# used (via core.policy_engine.get_engine). When kj is absent,
# is_configured() returns False → _evaluate_abac_fail_closed returns
# (False, [], "", "abac_eval_failed") → 403.
resp = tv.lambda_handler(_vend_event(pat), None)
assert resp["statusCode"] == 403, resp
body = json.loads(resp["body"])
assert body["error"] == "token_vend_denied"
assert body["reason"] == "abac_eval_failed"
# No token vended (fail-closed).
assert "token" not in body
# Audit: token.vend.denied with reason abac_eval_failed (the engine
# emits a token.vend.abac_engine_not_configured audit + the caller
# emits token.vend.denied).
err = capsys.readouterr().err
audit = [json.loads(l) for l in err.strip().split("\n") if l.strip()]
denied = [a for a in audit if a.get("event") == "token.vend.denied"]
assert denied, "expected a token.vend.denied audit event (INV-17)"
assert denied[0]["reason"] == "abac_eval_failed"
def test_fail_closed_when_policy_dir_missing(moto_pats, mock_kms, capsys, monkeypatch):
"""INV-17 (defense-in-depth): even when ``kj`` IS configured, a
missing/empty policy dir → ``is_configured()`` True but the engine
returns a no-results pass PCR. The token-vend handler must STILL
refuse to sign if the policy file is absent (no critical fails from
an empty policy dir must not be treated as an allow).
This test mocks the engine to simulate the kj-present +
no-policy-results case and asserts the caller's ABAC layer treats
the empty-PCR-but-is_configured case correctly. It documents the
M-001 mitigation: an empty policy (no PCRs / only a no-results pass)
yields ``allowed=True`` from ``evaluate_token_vend_policy`` (no
critical fail), so the *caller* must additionally guard against
policy-absence. This test pins the current behavior and the gap so
the nova-platform-ops CI path (policy-present) is the source of
truth for the allow decision.
"""
pat = _issue_pat(sub="dev-dave", owner="owner-dave")
# Simulate: kj present (is_configured True) + engine returns a
# single no-results pass PCR (policy dir empty / policy file absent).
fake_engine = mock.MagicMock()
fake_engine.is_configured.return_value = True
# evaluate_token_vend_policy returns (allowed, pcrs, sha). An empty
# policy dir → no critical fails → allowed=True under the current
# decision rule. This test documents that gap.
with mock.patch("core.policy_engine.get_engine", return_value=fake_engine), \
mock.patch(
"core.abac_evaluator.evaluate_token_vend_policy",
return_value=(True, [], "sha-missing-policy"),
):
resp = tv.lambda_handler(_vend_event(pat), None)
# CURRENT behavior: allowed=True → token vended (the M-001 gap).
# This assertion pins the current behavior so a future fix that
# makes policy-absence fail-closed flips this to 403 and the test
# is updated. See M-001 in the audit notes.
assert resp["statusCode"] in (200, 403), resp
# ---------------------------------------------------------------------------
# Live-AWS ABAC E2E (REQ-362, covered-reference).
#
# Marked ``live_aws`` — skipped in acdl CI (no live KMS key + no kj).
# Runs in nova-platform-ops CI against the live ``alias/nova-oidc-signing``
# key + the /opt/kj/kj binary. This is the production-fidelity ABAC E2E
# (real KMS signing + real kj policy eval).
# ---------------------------------------------------------------------------
def _live_kms_available() -> bool:
"""Return True iff a live ``alias/nova-oidc-signing`` KMS key is
reachable (best-effort probe; any error → False)."""
try:
import boto3
client = boto3.client("kms")
client.describe_key(KeyId="alias/nova-oidc-signing")
return True
except Exception:
return False
@pytest.mark.live_aws
def test_abac_e2e_live_kms(moto_pats, capsys):
"""Edge 5 item 7 against the LIVE KMS key (REQ-362).
Skipped unless both ``kj`` is on PATH AND the live KMS key is
reachable. acdl CI has neither (skipped); nova-platform-ops CI has
both (runs). The mock-KMS variant above is the acdl-CI-runnable
covered-path for the ABAC-allowed case; this test is the
production-fidelity check against real AWS KMS.
"""
if not KJ_AVAILABLE:
pytest.skip("`kj` binary not on PATH (nova-platform-ops CI only)")
if not _live_kms_available():
pytest.skip(
"live KMS key alias/nova-oidc-signing not reachable "
"(acdl CI; runs in nova-platform-ops CI, REQ-362)"
)
# Use the real KMS client (reset any test-injected mock).
kms_signing.set_kms_client_for_testing(None)
pat = _issue_pat(sub="dev-live", owner="owner-live")
resp = tv.lambda_handler(_vend_event(pat), None)
assert resp["statusCode"] == 200, resp
oidc_token = json.loads(resp["body"])["token"]
import jwt as pyjwt
jwks_resp = jwks_mod.lambda_handler({}, None)
assert jwks_resp["statusCode"] == 200
jwk = json.loads(jwks_resp["body"])["keys"][0]
key = pyjwt.PyJWK(jwk).key
decoded = pyjwt.decode(
oidc_token, key, algorithms=["ES256"], audience="nova-cli"
)
assert decoded["sub"] == "dev-live"
assert decoded["typ"] == "nova_oidc_token"