"""Nova IdP setup logic — check / apply / verify (REQ-340, REQ-341, C-2.1). Backing logic for ``nova idp setup``. The CLI (``nova/idp/setup.py``) is a thin ≤50-line delegate to this module (CAP-034). From v1.29 (REQ-369, spec §7.5) the active provisioning path is ``terraform apply`` in the ``nova-platform-ops`` checkout. The CFN template generated here is archived as read-only reference in ``docs/archive/nova-idp-cfn-v1.28.md``; :func:`generate_and_deploy` (the former CFN deploy path) emits a ``DeprecationWarning`` and is retained only as a fallback when terraform is absent from PATH. :func:`terraform_apply` and :func:`terraform_plan` are the new preferred paths. """ from __future__ import annotations import importlib.util import json import os import shutil import subprocess import sys import tempfile import warnings from pathlib import Path from typing import Any _CFN_ARCHIVE_REF = ( "CFN path is archived; install terraform or use nova-platform-ops. " "See docs/archive/nova-idp-cfn-v1.28.md." ) def _load_cfn(): """Load core/lambda/nova_idp_cfn.py via importlib (`lambda` is reserved).""" p = Path(__file__).parent / "nova_idp_cfn.py" spec = importlib.util.spec_from_file_location("nova_idp_cfn", p) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod _cfn = _load_cfn() generate_template = _cfn.generate_template resource_summary = _cfn.resource_summary def check_prerequisites() -> dict[str, Any]: """Check IdP setup prerequisites (AWS creds, CFN/IAM/KMS perms). Returns a report dict: ``{"aws_creds": bool, "region": str|None, "missing": [str], "iam_delta": [str]}`` """ report: dict[str, Any] = {"aws_creds": False, "region": None, "missing": [], "iam_delta": []} # AWS creds check. try: who = subprocess.check_output( ["aws", "sts", "get-caller-identity"], stderr=subprocess.DEVNULL, text=True, timeout=10 ) report["aws_creds"] = bool(json.loads(who).get("Account")) except Exception: report["missing"].append("aws_credentials (run `aws configure`)") # Region. region = os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION") report["region"] = region if not region: report["missing"].append("aws_region (set AWS_DEFAULT_REGION)") # IAM policy delta (the grants the deploying principal needs). report["iam_delta"] = [ "cloudformation:*", "iam:CreateRole", "iam:PassRole", "lambda:CreateFunction", "lambda:CreateFunctionUrlConfig", "dynamodb:CreateTable", "kms:CreateKey", "kms:CreateAlias", ] return report def generate_and_deploy( public_jwks_domain: str | None = None, dry_run: bool = False, approve_fn=None, ) -> dict[str, Any]: """Generate the CFN template + deploy (REQ-341, NFR-10 y/N approval). .. deprecated:: v1.29 The active path is :func:`terraform_apply` (REQ-369, spec §7.5). This CFN deploy path is archived as read-only reference in ``docs/archive/nova-idp-cfn-v1.28.md`` and retained only as a fallback when terraform is absent from PATH. It emits a ``DeprecationWarning`` on every non-dry-run invocation. Args: public_jwks_domain: optional custom JWKS domain. dry_run: if True, print the resource summary only (no deploy). approve_fn: callable returning True/False for the y/N prompt (defaults to stdin readline). Returns: ``{"template": , "summary": , "deployed": bool}``. """ template = generate_template(public_jwks_domain) summary = resource_summary(template) if dry_run: return {"template": template, "summary": summary, "deployed": False} warnings.warn(_CFN_ARCHIVE_REF, DeprecationWarning, stacklevel=2) # NFR-10: explicit y/N approval before cloudformation deploy. print("Resource summary:") for rtype, count in sorted(summary.items()): print(f" {rtype}: {count}") # Print template to a temp file + open $PAGER. tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8") json.dump(template, tmp, indent=2); tmp.flush(); tmp.close() pager = os.environ.get("PAGER") if pager and sys.stdin.isatty(): try: subprocess.run([pager, tmp.name]) except Exception: print(f"(template at {tmp.name})") else: print(f"(template at {tmp.name})") # y/N prompt. if approve_fn is None: answer = input("Apply? [y/N] ").strip().lower() else: answer = "y" if approve_fn() else "n" if answer != "y": print("aborted (no approval)") return {"template": template, "summary": summary, "deployed": False} # cloudformation deploy. stack_name = os.environ.get("NOVA_IDP_STACK_NAME", "nova-idp") try: subprocess.check_call([ "aws", "cloudformation", "deploy", "--stack-name", stack_name, "--template-file", tmp.name, "--capabilities", "CAPABILITY_IAM", ]) deployed = True except Exception as e: print(f"deploy failed: {e}", file=sys.stderr) deployed = False return {"template": template, "summary": summary, "deployed": deployed} def terraform_apply(*, auto_approve: bool = True) -> dict[str, Any]: """Delegate provisioning to ``terraform apply`` (REQ-369, spec §7.5). The operator runs this from the ``nova-platform-ops`` checkout root (where the Terraform modules live). This function shells out to ``terraform`` on PATH; the caller (``nova/idp/setup.py``) is responsible for the ``shutil.which("terraform")`` gate. Args: auto_approve: pass ``-auto-approve`` (default True; the y/N gate is the operator's PR review in nova-platform-ops). Returns: ``{"deployed": bool, "returncode": int, "command": [str]}``. """ cmd = ["terraform", "apply"] if auto_approve: cmd.append("-auto-approve") proc = subprocess.run(cmd) return {"deployed": proc.returncode == 0, "returncode": proc.returncode, "command": cmd} def terraform_plan() -> dict[str, Any]: """Delegate verification to ``terraform plan`` (REQ-369, spec §7.5). Reports the diff between the live stack and the Terraform source in the ``nova-platform-ops`` checkout. The caller is responsible for the ``shutil.which("terraform")`` gate. Returns: ``{"passed": bool, "returncode": int, "command": [str]}``. """ cmd = ["terraform", "plan"] proc = subprocess.run(cmd) return {"passed": proc.returncode == 0, "returncode": proc.returncode, "command": cmd} def verify() -> dict[str, Any]: """Run the KMS round-trip verification (REQ-340 --verify). Delegates to the CAP-037 test logic: sign a JWT (mock KMS) → JWKS → pyjwt verify. Returns ``{"passed": bool, "detail": str}``. """ try: 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 priv = ec.generate_private_key(ec.SECP256R1()) pub_der = priv.public_key().public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) class _MockKms: def sign(self, KeyId, Message, MessageType, SigningAlgorithm): return {"Signature": priv.sign(Message, ec.ECDSA(hashes.SHA256()))} def get_public_key(self, KeyId): return {"PublicKey": pub_der} kms_signing.set_kms_client_for_testing(_MockKms()) token = kms_signing.sign_jwt({"sub": "verify", "exp": 9999999999, "iat": 1, "jti": "v"}) jwk = kms_signing.get_jwk() key = pyjwt.PyJWK(jwk).key decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False}) ok = decoded["sub"] == "verify" return {"passed": ok, "detail": "KMS round-trip OK" if ok else "mismatch"} except Exception as e: return {"passed": False, "detail": f"verify error: {e}"} finally: try: kms_signing.set_kms_client_for_testing(None) except Exception: pass if __name__ == "__main__": # pragma: no cover - CLI inspection helper mode = sys.argv[1] if len(sys.argv) > 1 else "--check" if mode == "--check": print(json.dumps(check_prerequisites(), indent=2)) elif mode == "--dry-run": print(json.dumps(generate_and_deploy(dry_run=True)["summary"], indent=2)) elif mode == "--verify": print(json.dumps(verify(), indent=2)) else: print("usage: nova_idp_setup.py --check|--dry-run|--verify", file=sys.stderr)