"""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))