932923ee99
Nova Slides Render / render (push) Failing after 22s
---ci--- project: acdl phase: 6 milestone: v1.29 status: complete ---/ci---
457 lines
19 KiB
Python
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" |