docs(P01): complete publish-pipeline phase (REQ-354, v1.28.1)
Nova Slides Render / render (push) Failing after 24s
Nova Slides Render / render (push) Failing after 24s
---ci--- project: acdl phase: 1 milestone: v1.29 status: complete ---/ci---
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
"""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"
|
||||
@@ -81,4 +81,65 @@ def test_cap037_kms_roundtrip():
|
||||
assert decoded["sub"] == "roundtrip-user"
|
||||
assert decoded["jti"] == "rt-jti"
|
||||
assert decoded["roles"] == ["developer"]
|
||||
assert decoded["typ"] == "nova_oidc_token"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live-KMS round-trip (REQ-362, Edge 5 item 6).
|
||||
#
|
||||
# This test is marked ``@pytest.mark.live_aws`` and is SKIPPED in acdl CI
|
||||
# (the live KMS key ``alias/nova-oidc-signing`` is not provisioned here).
|
||||
# It runs in nova-platform-ops CI against the real KMS key, REQ-362
|
||||
# (covered-reference — verification surface is the nova-platform-ops
|
||||
# pipeline, not acdl's). It exercises the same sign → JWKS → verify path
|
||||
# against the production key/alias so the DER→raw conversion + JWK export
|
||||
# are verified end-to-end against real AWS KMS.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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_cap037_kms_roundtrip_live():
|
||||
"""Sign → JWKS → pyjwt verify against the LIVE KMS key
|
||||
(``alias/nova-oidc-signing``). Edge 5 item 6, REQ-362.
|
||||
|
||||
Skipped unless a live KMS key is reachable (acdl CI has none; this
|
||||
runs in nova-platform-ops CI). The mock-based ``test_cap037_kms_roundtrip``
|
||||
above is the acdl-CI-runnable covered-path.
|
||||
"""
|
||||
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 client).
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
claims = {
|
||||
"sub": "live-roundtrip-user", "aud": "nova-cli", "iss": "nova-idp",
|
||||
"exp": 9999999999, "iat": 1700000000, "jti": "live-rt-jti",
|
||||
"roles": ["developer"], "typ": "nova_oidc_token",
|
||||
}
|
||||
token = kms_signing.sign_jwt(claims, key_id="alias/nova-oidc-signing")
|
||||
|
||||
resp = jwks_mod.lambda_handler({}, None)
|
||||
assert resp["statusCode"] == 200, resp
|
||||
jwk = json.loads(resp["body"])["keys"][0]
|
||||
assert jwk["kty"] == "EC" and jwk["crv"] == "P-256"
|
||||
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(token, key, algorithms=["ES256"], audience="nova-cli")
|
||||
assert decoded["sub"] == "live-roundtrip-user"
|
||||
assert decoded["jti"] == "live-rt-jti"
|
||||
assert decoded["typ"] == "nova_oidc_token"
|
||||
Reference in New Issue
Block a user