feat(P04): kyverno-json ABAC policy + evaluator (REQ-339, D-227, C-5.1, security-engineer)
---ci--- project: acdl phase: 4 milestone: v1.28 status: execute persona: security-engineer ---
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""Nova ABAC evaluator for the token-vend Lambda (REQ-339, C-6.1, D-231).
|
||||
|
||||
Wraps :func:`core.policy_engine.get_engine` to evaluate the
|
||||
``platform/abac/token-vend.policy`` kyverno-json ``ValidatingPolicy``
|
||||
against a token-vend authorization payload and produce an allow/deny
|
||||
decision with the policy SHA (D-231).
|
||||
|
||||
Payload shape (REQ-339, C-5.1)::
|
||||
|
||||
{
|
||||
"subject": {"id": ..., "role": ..., "owner": ...},
|
||||
"requested_claims": [<claim name>, ...], # C-5.1
|
||||
"target_resource": {"type": ..., "id": ..., "owner": ..., "environment": ...},
|
||||
"environment": "dev" | "qa" | "prod" | "dr",
|
||||
"pat_jti": "<PAT jti>",
|
||||
"policy_version": "<git SHA>"
|
||||
}
|
||||
|
||||
Decision rule (C-6.1 fail-closed): **any** PCR with ``result == "fail"``
|
||||
and ``severity == "critical"`` → ``allowed=False``. The caller (the
|
||||
token-vend Lambda) is additionally required to fail closed when
|
||||
``KyvernoJsonEngine.is_configured()`` returns ``False`` or when this
|
||||
function raises — see ``tests/test_abac_fail_closed.py`` (the grill's
|
||||
#1 finding, INV-17).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
from core.policy_engine import get_engine
|
||||
|
||||
|
||||
_POLICY_DIR = Path("platform/abac")
|
||||
_POLICY_FILE = _POLICY_DIR / "token-vend.policy"
|
||||
_CONTRACT_ID = "token-vend"
|
||||
|
||||
|
||||
def _materialize_policy_dir(src_dir: Path) -> Tuple[Path, bool]:
|
||||
"""Mirror ``src_dir`` to a temp dir, copying ``*.policy`` files to
|
||||
``*.json`` twins (JSON is a valid kyverno-json policy format; the
|
||||
``KyvernoJsonEngine`` only loads ``.json``/``.yaml``/``.yml``, and
|
||||
Nova ABAC policies use the ``.policy`` extension per REQ-339, so a
|
||||
byte-for-byte copy with a ``.json`` extension is required).
|
||||
|
||||
Returns ``(temp_dir, created)``; ``created`` is ``False`` when no
|
||||
policy files were found. The caller is responsible for removing the
|
||||
temp dir.
|
||||
"""
|
||||
tmp = Path(tempfile.mkdtemp(prefix="nova-abac-pol-"))
|
||||
any_policy = False
|
||||
if src_dir.is_dir():
|
||||
for entry in sorted(os.listdir(src_dir)):
|
||||
if entry.startswith(".") or entry.startswith("_"):
|
||||
continue
|
||||
src_file = src_dir / entry
|
||||
if not src_file.is_file():
|
||||
continue
|
||||
if entry.endswith(".policy"):
|
||||
dest = tmp / (entry[: -len(".policy")] + ".json")
|
||||
shutil.copy2(src_file, dest)
|
||||
any_policy = True
|
||||
elif entry.endswith((".json", ".yaml", ".yml")):
|
||||
shutil.copy2(src_file, tmp / entry)
|
||||
any_policy = True
|
||||
return tmp, any_policy
|
||||
|
||||
|
||||
def _policy_sha() -> str:
|
||||
"""Return the git SHA of the policy file (D-231).
|
||||
|
||||
Uses ``git rev-parse HEAD:platform/abac/token-vend.policy`` so the
|
||||
SHA is stable across checkouts (blob SHA, not commit SHA). Falls
|
||||
back to ``"unknown"`` when git is unavailable or the file is not
|
||||
tracked (e.g. during local development before the first commit).
|
||||
"""
|
||||
repo_root = os.environ.get("NOVA_REPO_ROOT") or os.getcwd()
|
||||
try:
|
||||
sha = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD:platform/abac/token-vend.policy"],
|
||||
cwd=repo_root,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=5,
|
||||
).strip()
|
||||
return sha or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def evaluate_token_vend_policy(
|
||||
payload: dict,
|
||||
) -> Tuple[bool, list, str]:
|
||||
"""Evaluate the token-vend ABAC policy against ``payload``.
|
||||
|
||||
Args:
|
||||
payload: the ABAC authorization payload (see module docstring).
|
||||
|
||||
Returns:
|
||||
``(allowed, pcrs, policy_sha)`` where ``allowed`` is ``True``
|
||||
iff no PCR has ``result == "fail"`` with ``severity ==
|
||||
"critical"`` (C-6.1). ``pcrs`` is the raw list of
|
||||
``PolicyCheckResult`` dicts from the engine. ``policy_sha`` is
|
||||
the git blob SHA of the policy file (D-231).
|
||||
|
||||
Raises:
|
||||
Exception: any engine error propagates — the caller MUST catch
|
||||
and fail closed (403 ``abac_eval_failed``). This function
|
||||
does NOT swallow errors: failing closed is the *caller's*
|
||||
responsibility so the denial audit event is emitted at the
|
||||
Lambda boundary with the right reason code.
|
||||
"""
|
||||
engine = get_engine()
|
||||
# Nova ABAC policies use the `.policy` extension (REQ-339), but
|
||||
# KyvernoJsonEngine only loads `.json`/`.yaml`/`.yml`. Materialize a
|
||||
# temp dir with `.policy` → `.json` twins so the engine picks them
|
||||
# up. The temp dir is removed in the `finally` block.
|
||||
pol_dir, _ = _materialize_policy_dir(_POLICY_DIR)
|
||||
try:
|
||||
pcrs = engine.evaluate(payload, pol_dir, _CONTRACT_ID)
|
||||
finally:
|
||||
shutil.rmtree(pol_dir, ignore_errors=True)
|
||||
allowed = not any(
|
||||
p.get("result") == "fail" and str(p.get("severity", "")).lower() == "critical"
|
||||
for p in pcrs
|
||||
)
|
||||
return allowed, pcrs, _policy_sha()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
with open(sys.argv[1]) as fh:
|
||||
pl = json.load(fh)
|
||||
else:
|
||||
pl = json.loads(sys.stdin.read())
|
||||
allowed, pcrs, sha = evaluate_token_vend_policy(pl)
|
||||
print(json.dumps({"allowed": allowed, "policy_sha": sha, "pcrs": pcrs}, indent=2))
|
||||
Reference in New Issue
Block a user