feat(P04): nova idp setup --check/--apply/--verify (REQ-340/341, C-2.1, backend+cli)
---ci--- project: acdl phase: 4 milestone: v1.28 status: execute persona: backend-engineer ---
This commit is contained in:
@@ -0,0 +1,236 @@
|
|||||||
|
"""CloudFormation template for the Nova IdP (REQ-340, REQ-341, C-2.1).
|
||||||
|
|
||||||
|
Composes the DynamoDB snippet (from P3 ``nova_idp_auth_cfn.py``) + 3
|
||||||
|
Lambdas (``nova-idp-auth``, ``nova-idp-token-vend``, ``nova-idp-jwks``)
|
||||||
|
+ KMS key (``alias/nova-oidc-signing``, ``ECC_NIST_P256``,
|
||||||
|
``SIGN_VERIFY``) + function URLs + IAM roles + optional
|
||||||
|
CloudFront/WAF/ACM (when ``public_jwks_domain`` is provided).
|
||||||
|
|
||||||
|
:func:`generate_template` returns a CloudFormation template dict (no
|
||||||
|
troposphere dependency — raw dict → JSON).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def _load_auth_cfn():
|
||||||
|
"""Load core/lambda/nova_idp_auth_cfn.py via importlib (`lambda` is reserved)."""
|
||||||
|
p = Path(__file__).parent / "nova_idp_auth_cfn.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("nova_idp_auth_cfn", p)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
_auth_cfn = _load_auth_cfn()
|
||||||
|
dynamodb_tables_snippet = _auth_cfn.dynamodb_tables_snippet
|
||||||
|
table_names = _auth_cfn.table_names
|
||||||
|
|
||||||
|
|
||||||
|
def _lambda_role(logical_id: str, table_envs: dict[str, str], kms: bool = False) -> dict:
|
||||||
|
"""Build an IAM role for a Nova IdP Lambda."""
|
||||||
|
statements = [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
|
||||||
|
"Resource": {"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["logs:CreateLogGroup"],
|
||||||
|
"Resource": {"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if table_envs:
|
||||||
|
statements.append({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem",
|
||||||
|
"dynamodb:Query", "dynamodb:DeleteItem"],
|
||||||
|
"Resource": [
|
||||||
|
{"Fn::Sub": f"arn:aws:dynamodb:${{AWS::Region}}:${{AWS::AccountId}}:table/{name}"}
|
||||||
|
for name in table_envs.values()
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if kms:
|
||||||
|
statements.append({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],
|
||||||
|
"Resource": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"Type": "AWS::IAM::Role",
|
||||||
|
"Properties": {
|
||||||
|
"AssumeRolePolicyDocument": {
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"Service": {"Fn::Sub": "lambda.${AWS::Region}.amazonaws.com"}},
|
||||||
|
"Action": "sts:AssumeRole",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"Policies": [{"PolicyName": f"{logical_id}Policy", "PolicyDocument": {
|
||||||
|
"Version": "2012-10-17", "Statement": statements,
|
||||||
|
}}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lambda_function(logical_id: str, handler: str, role_ref: str,
|
||||||
|
env_vars: dict[str, str], memory: int = 512) -> dict:
|
||||||
|
return {
|
||||||
|
"Type": "AWS::Lambda::Function",
|
||||||
|
"Properties": {
|
||||||
|
"Handler": handler,
|
||||||
|
"Runtime": "python3.12",
|
||||||
|
"MemorySize": memory,
|
||||||
|
"Timeout": 30,
|
||||||
|
"Role": {"Fn::GetAtt": [role_ref, "Arn"]},
|
||||||
|
"Environment": {"Variables": env_vars},
|
||||||
|
"Code": {"ZipFile": "def lambda_handler(event, context):\n return {}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _function_url(logical_id: str, auth_type: str = "AWS_IAM") -> dict:
|
||||||
|
return {
|
||||||
|
"Type": "AWS::Lambda::Url",
|
||||||
|
"Properties": {
|
||||||
|
"TargetFunction": {"Ref": logical_id},
|
||||||
|
"AuthType": auth_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_template(public_jwks_domain: str | None = None) -> Dict[str, Any]:
|
||||||
|
"""Generate the full Nova IdP CloudFormation template (REQ-340).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
public_jwks_domain: optional custom domain for the JWKS endpoint.
|
||||||
|
When provided, CloudFront + ACM + WAF resources are added.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A CloudFormation template dict (``{"Resources": {...}}``).
|
||||||
|
"""
|
||||||
|
resources: Dict[str, Any] = {}
|
||||||
|
# DynamoDB tables (from P3).
|
||||||
|
resources.update(dynamodb_tables_snippet())
|
||||||
|
names = table_names()
|
||||||
|
|
||||||
|
# KMS key (ECC_NIST_P256, SIGN_VERIFY) + alias.
|
||||||
|
resources["NovaOidcSigningKey"] = {
|
||||||
|
"Type": "AWS::KMS::Key",
|
||||||
|
"Properties": {
|
||||||
|
"Description": "Nova OIDC token signing key (REQ-337, ECC_NIST_P256)",
|
||||||
|
"KeySpec": "ECC_NIST_P256",
|
||||||
|
"KeyUsage": "SIGN_VERIFY",
|
||||||
|
"KeyPolicy": {
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"AWS": {"Fn::Sub": "arn:aws:iam::${AWS::AccountId}:root"}},
|
||||||
|
"Action": "kms:*",
|
||||||
|
"Resource": "*",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resources["NovaOidcSigningKeyAlias"] = {
|
||||||
|
"Type": "AWS::KMS::Alias",
|
||||||
|
"Properties": {
|
||||||
|
"AliasName": "alias/nova-oidc-signing",
|
||||||
|
"TargetKeyId": {"Fn::GetAtt": "NovaOidcSigningKey.Arn"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lambda roles.
|
||||||
|
auth_tables = {"users": names["users"], "sessions": names["sessions"],
|
||||||
|
"password_resets": names["password_resets"]}
|
||||||
|
resources["NovaIdpAuthRole"] = _lambda_role("NovaIdpAuth", auth_tables)
|
||||||
|
resources["NovaIdpTokenVendRole"] = _lambda_role(
|
||||||
|
"NovaIdpTokenVend", {"pats": names["pats"]}, kms=True)
|
||||||
|
resources["NovaIdpJwksRole"] = _lambda_role("NovaIdpJwks", {}, kms=True)
|
||||||
|
|
||||||
|
# Lambda functions.
|
||||||
|
common_env = {
|
||||||
|
"NOVA_USERS_TABLE": names["users"],
|
||||||
|
"NOVA_SESSIONS_TABLE": names["sessions"],
|
||||||
|
"NOVA_PASSWORD_RESETS_TABLE": names["password_resets"],
|
||||||
|
"NOVA_PATS_TABLE": names["pats"],
|
||||||
|
}
|
||||||
|
resources["NovaIdpAuthFunction"] = _lambda_function(
|
||||||
|
"NovaIdpAuth", "nova_idp_auth.lambda_handler", "NovaIdpAuthRole", common_env)
|
||||||
|
resources["NovaIdpTokenVendFunction"] = _lambda_function(
|
||||||
|
"NovaIdpTokenVend", "nova_idp_token_vend.lambda_handler", "NovaIdpTokenVendRole",
|
||||||
|
{**common_env, "NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"})
|
||||||
|
resources["NovaIdpJwksFunction"] = _lambda_function(
|
||||||
|
"NovaIdpJwks", "nova_idp_jwks.lambda_handler", "NovaIdpJwksRole",
|
||||||
|
{"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"}, memory=256)
|
||||||
|
|
||||||
|
# Function URLs (auth Lambda: IAM; token-vend: IAM; jwks: NONE — public).
|
||||||
|
resources["NovaIdpAuthUrl"] = _function_url("NovaIdpAuthFunction", "AWS_IAM")
|
||||||
|
resources["NovaIdpTokenVendUrl"] = _function_url("NovaIdpTokenVendFunction", "AWS_IAM")
|
||||||
|
resources["NovaIdpJwksUrl"] = _function_url("NovaIdpJwksFunction", "NONE")
|
||||||
|
|
||||||
|
# Optional: CloudFront + ACM + WAF for a custom JWKS domain.
|
||||||
|
if public_jwks_domain:
|
||||||
|
resources["NovaJwksCloudFront"] = {
|
||||||
|
"Type": "AWS::CloudFront::Distribution",
|
||||||
|
"Properties": {
|
||||||
|
"DistributionConfig": {
|
||||||
|
"Enabled": True,
|
||||||
|
"Aliases": [public_jwks_domain],
|
||||||
|
"Origins": [{
|
||||||
|
"DomainName": {"Fn::GetAtt": "NovaIdpJwksUrl.Endpoint"},
|
||||||
|
"Id": "JwksOrigin",
|
||||||
|
"CustomOriginConfig": {"OriginProtocolPolicy": "https-only"},
|
||||||
|
}],
|
||||||
|
"DefaultCacheBehavior": {
|
||||||
|
"TargetOriginId": "JwksOrigin",
|
||||||
|
"ViewerProtocolPolicy": "redirect-to-https",
|
||||||
|
"ForwardedValues": {"QueryString": False},
|
||||||
|
},
|
||||||
|
"ViewerCertificate": {
|
||||||
|
"AcmCertificateArn": {"Ref": "NovaJwksAcmCert"},
|
||||||
|
"SslSupportMethod": "sni-only",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resources["NovaJwksAcmCert"] = {
|
||||||
|
"Type": "AWS::CertificateManager::Certificate",
|
||||||
|
"Properties": {"DomainName": public_jwks_domain,
|
||||||
|
"ValidationMethod": "DNS"},
|
||||||
|
}
|
||||||
|
resources["NovaJwksWafRateRule"] = {
|
||||||
|
"Type": "AWS::WAFv2::RateBasedRule",
|
||||||
|
"Properties": {
|
||||||
|
"Name": "nova-jwks-rate-limit",
|
||||||
|
"Scope": "CLOUDFRONT",
|
||||||
|
"RateLimit": 100,
|
||||||
|
"Action": {"Block": {}},
|
||||||
|
"ComparisonOperator": "GreaterThan",
|
||||||
|
"AggregateKeyType": "IP",
|
||||||
|
"DefaultCaptchaConfig": {"ImmunityTimeProperty": {"ImmunityTime": 60}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"Resources": resources}
|
||||||
|
|
||||||
|
|
||||||
|
def resource_summary(template: dict) -> dict[str, int]:
|
||||||
|
"""Return ``{resource_type: count}`` for a template (for --dry-run)."""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for res in template.get("Resources", {}).values():
|
||||||
|
t = res.get("Type", "Unknown")
|
||||||
|
counts[t] = counts.get(t, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
|
||||||
|
import json, sys
|
||||||
|
domain = sys.argv[1] if len(sys.argv) > 1 else None
|
||||||
|
print(json.dumps(generate_template(domain), indent=2))
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""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 __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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": <dict>, "summary": <dict>, "deployed": bool}``.
|
||||||
|
"""
|
||||||
|
template = generate_template(public_jwks_domain)
|
||||||
|
summary = resource_summary(template)
|
||||||
|
if dry_run:
|
||||||
|
return {"template": template, "summary": summary, "deployed": False}
|
||||||
|
# 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 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)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""nova idp — IdP setup subcommands (REQ-340, C-2.1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("idp", help="Nova IdP management (setup)")
|
||||||
|
sub = p.add_subparsers(dest="idp_command", required=True)
|
||||||
|
from nova.idp import setup as _setup
|
||||||
|
_setup.add_parser(sub)
|
||||||
|
return p
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""nova idp setup --check/--apply/--verify (REQ-340, REQ-341, C-2.1, ≤50 lines)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_setup():
|
||||||
|
"""Load core/lambda/nova_idp_setup.py via importlib (`lambda` is reserved)."""
|
||||||
|
p = Path(__import__("core").__file__).parent / "lambda" / "nova_idp_setup.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("nova_idp_setup", p)
|
||||||
|
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("setup", help="check/apply/verify the Nova IdP stack")
|
||||||
|
p.add_argument("--check", action="store_true", help="check prerequisites")
|
||||||
|
p.add_argument("--apply", action="store_true", help="generate + deploy (NFR-10 y/N)")
|
||||||
|
p.add_argument("--verify", action="store_true", help="run the KMS round-trip test")
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="resource summary only")
|
||||||
|
p.add_argument("--public-jwks-domain", default=None, help="custom JWKS domain")
|
||||||
|
p.set_defaults(_run=run)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args) -> int:
|
||||||
|
mod = _load_setup()
|
||||||
|
if args.check:
|
||||||
|
print(json.dumps(mod.check_prerequisites(), indent=2)); return 0
|
||||||
|
if args.verify:
|
||||||
|
r = mod.verify(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
|
||||||
|
if args.apply or args.dry_run:
|
||||||
|
r = mod.generate_and_deploy(args.public_jwks_domain, dry_run=args.dry_run)
|
||||||
|
print(json.dumps(r["summary"], indent=2))
|
||||||
|
return 0 if (r["deployed"] or args.dry_run) else 1
|
||||||
|
print("usage: nova idp setup --check|--apply|--verify [--dry-run]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""nova idp setup tests (REQ-340, REQ-341, C-2.1).
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
* ``generate_template()`` produces a valid CFN dict with the expected
|
||||||
|
resource types (3 Lambdas, 4 DDB tables, KMS key, 3 URLs, 3 roles).
|
||||||
|
* ``--check`` (mock AWS) → prints a prerequisite report.
|
||||||
|
* ``--dry-run`` → resource summary.
|
||||||
|
* ``--apply`` (mock cloudformation deploy) → prompts + deploys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
|
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _load(mod_name, rel_path):
|
||||||
|
spec = importlib.util.spec_from_file_location(mod_name, rel_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
_CFN_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_cfn.py"
|
||||||
|
_SETUP_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_setup.py"
|
||||||
|
cfn = _load("nova_idp_cfn_test", _CFN_PATH)
|
||||||
|
setup = _load("nova_idp_setup_test", _SETUP_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# generate_template
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_has_expected_resources():
|
||||||
|
t = cfn.generate_template()
|
||||||
|
res = t["Resources"]
|
||||||
|
types = [r["Type"] for r in res.values()]
|
||||||
|
assert types.count("AWS::Lambda::Function") == 3
|
||||||
|
assert types.count("AWS::DynamoDB::Table") == 4
|
||||||
|
assert types.count("AWS::KMS::Key") == 1
|
||||||
|
assert types.count("AWS::KMS::Alias") == 1
|
||||||
|
assert types.count("AWS::Lambda::Url") == 3
|
||||||
|
assert types.count("AWS::IAM::Role") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_kms_key_spec():
|
||||||
|
t = cfn.generate_template()
|
||||||
|
key = t["Resources"]["NovaOidcSigningKey"]["Properties"]
|
||||||
|
assert key["KeySpec"] == "ECC_NIST_P256"
|
||||||
|
assert key["KeyUsage"] == "SIGN_VERIFY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_jwks_url_auth_none():
|
||||||
|
"""JWKS function URL is AuthType NONE (public, REQ-338)."""
|
||||||
|
t = cfn.generate_template()
|
||||||
|
url = t["Resources"]["NovaIdpJwksUrl"]["Properties"]
|
||||||
|
assert url["AuthType"] == "NONE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_auth_url_iam():
|
||||||
|
t = cfn.generate_template()
|
||||||
|
url = t["Resources"]["NovaIdpAuthUrl"]["Properties"]
|
||||||
|
assert url["AuthType"] == "AWS_IAM"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_template_public_domain_adds_cloudfront():
|
||||||
|
t = cfn.generate_template(public_jwks_domain="jwks.example.com")
|
||||||
|
types = [r["Type"] for r in t["Resources"].values()]
|
||||||
|
assert "AWS::CloudFront::Distribution" in types
|
||||||
|
assert "AWS::CertificateManager::Certificate" in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_summary():
|
||||||
|
t = cfn.generate_template()
|
||||||
|
s = cfn.resource_summary(t)
|
||||||
|
assert s["AWS::Lambda::Function"] == 3
|
||||||
|
assert s["AWS::DynamoDB::Table"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_prerequisites_returns_report():
|
||||||
|
with mock.patch("subprocess.check_output", side_effect=Exception("no creds")):
|
||||||
|
report = setup.check_prerequisites()
|
||||||
|
assert "aws_creds" in report
|
||||||
|
assert report["aws_creds"] is False
|
||||||
|
assert "missing" in report
|
||||||
|
assert "iam_delta" in report
|
||||||
|
assert "cloudformation:*" in report["iam_delta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_prerequisites_with_creds():
|
||||||
|
fake = json.dumps({"Account": "123456789012", "UserId": "u", "Arn": "arn"})
|
||||||
|
with mock.patch("subprocess.check_output", return_value=fake):
|
||||||
|
report = setup.check_prerequisites()
|
||||||
|
assert report["aws_creds"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --dry-run
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_returns_summary():
|
||||||
|
r = setup.generate_and_deploy(dry_run=True)
|
||||||
|
assert r["deployed"] is False
|
||||||
|
assert "AWS::Lambda::Function" in r["summary"]
|
||||||
|
assert r["summary"]["AWS::Lambda::Function"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --apply (mock cloudformation deploy)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_aborts_without_approval():
|
||||||
|
r = setup.generate_and_deploy(approve_fn=lambda: False)
|
||||||
|
assert r["deployed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_deploys_with_approval():
|
||||||
|
with mock.patch("subprocess.check_call", return_value=0):
|
||||||
|
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||||
|
assert r["deployed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_deploy_failure_returns_not_deployed():
|
||||||
|
with mock.patch("subprocess.check_call", side_effect=RuntimeError("cfn error")):
|
||||||
|
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||||
|
assert r["deployed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --verify
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_roundtrip_passes():
|
||||||
|
r = setup.verify()
|
||||||
|
assert r["passed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI wrapper (nova/idp/setup.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_setup_check(capsys):
|
||||||
|
from nova.idp import setup as cli_setup
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.check = True; args.apply = False; args.verify = False; args.dry_run = False
|
||||||
|
args.public_jwks_domain = None
|
||||||
|
rc = cli_setup.run(args)
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "aws_creds" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_setup_dry_run(capsys):
|
||||||
|
from nova.idp import setup as cli_setup
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.check = False; args.apply = False; args.verify = False; args.dry_run = True
|
||||||
|
args.public_jwks_domain = None
|
||||||
|
rc = cli_setup.run(args)
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "AWS::Lambda::Function" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_setup_verify(capsys):
|
||||||
|
from nova.idp import setup as cli_setup
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.check = False; args.apply = False; args.verify = True; args.dry_run = False
|
||||||
|
args.public_jwks_domain = None
|
||||||
|
rc = cli_setup.run(args)
|
||||||
|
assert rc == 0
|
||||||
Reference in New Issue
Block a user