Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c0c2dd268 |
+10
-10
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"phase": 2,
|
||||
"phase": 3,
|
||||
"stage": "verify",
|
||||
"milestone": "v1.29",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-20T01:10:00Z",
|
||||
"updated_at": "2026-08-20T01:20:00Z",
|
||||
"project": "acdl",
|
||||
"projects": ["acdl", "nova-blockchain-exchange"],
|
||||
"active_milestone": "v1.29",
|
||||
"milestone_branch": "milestone/v1.29-reposplit-identity",
|
||||
"phase_branch": "phase/02-gitea-scrub-decisions",
|
||||
"phase_branch": "phase/03-cfn-archive-tf-delegation",
|
||||
"tag_line": "v1.28.x",
|
||||
"phase_name": "gitea-scrub-decisions",
|
||||
"phase_name": "cfn-archive-tf-delegation",
|
||||
"milestone_type": "feature",
|
||||
"reqs_covered": ["REQ-354", "REQ-367", "REQ-368"],
|
||||
"reqs_covered": ["REQ-354", "REQ-367", "REQ-368", "REQ-369"],
|
||||
"reqs_partial": [],
|
||||
"verification": {
|
||||
"structural": "PASS (py_compile exit 0 on 5 test files, bash -n OK on scripts)",
|
||||
"behavioral": "PASS (grep -rni gitea .github/ docs/ pyproject.toml README.md scripts/ -> zero matches; .gitea/ absent; forge_parity_disabled CI job added)",
|
||||
"security": "PASS (D-232 forge parity abandoned, CI asserts forge_parity_disabled)",
|
||||
"quality": "PASS (forge parity tests updated to assert disabled state, D-232 documented)"
|
||||
"structural": "PASS (py_compile exit 0, setup.py 44 lines <=50 CAP-034)",
|
||||
"behavioral": "PASS (11 assertions pass via stdlib harness: terraform delegation, CFN fallback deprecation, verify delegation)",
|
||||
"security": "PASS (CFN archived read-only, deprecation warning guides to terraform path)",
|
||||
"quality": "PASS (550-line archive with full CFN template, 216-line test file covering all paths)"
|
||||
},
|
||||
"notes": "v1.29 P2 EXECUTE+VERIFY complete. .gitea/ removed (7 files), scripts/sync_workflows.py + ship_phase.sh + attach_release_asset.py removed, scripts/rotate_spike_key.sh + sync_to_nova.sh scrubbed, pyproject.toml -> 1.29.0, forge_parity_disabled CI job added, 5 forge parity tests updated. D-232..240 verified present in PROJECT.md/CLARIFY/REQUIREMENTS."
|
||||
"notes": "v1.29 P3 EXECUTE+VERIFY complete. CFN template archived to docs/archive/nova-idp-cfn-v1.28.md (550 lines, full template). nova/idp/setup.py --apply delegates to terraform apply (shutil.which detection), --verify delegates to terraform plan. CFN fallback emits DeprecationWarning. test_idp_setup_tf_delegation.py authored (11 assertions)."
|
||||
}
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
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
|
||||
@@ -9,13 +18,21 @@ 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"
|
||||
@@ -71,6 +88,13 @@ def generate_and_deploy(
|
||||
) -> 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).
|
||||
@@ -84,6 +108,7 @@ def generate_and_deploy(
|
||||
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()):
|
||||
@@ -123,6 +148,43 @@ def generate_and_deploy(
|
||||
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).
|
||||
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
# Archived: Nova IdP CloudFormation Template (v1.28)
|
||||
|
||||
> **Archived at v1.29.0** — the active path is `terraform apply` in
|
||||
> `nova-platform-ops`. Deletion is a follow-up after Terraform parity
|
||||
> is verified (REQ-369 AC 3, spec §7.5). This template is read-only
|
||||
> reference; do not modify it. The `nova idp setup --apply` command
|
||||
> now delegates to `terraform apply` (see `nova/idp/setup.py`).
|
||||
|
||||
This is the verbatim output of `generate_template()` from
|
||||
`core/lambda/nova_idp_cfn.py` (the composition of the DynamoDB snippet
|
||||
from `core/lambda/nova_idp_auth_cfn.py` + the KMS signing key + the
|
||||
three IdP Lambdas + their IAM roles + function URLs). It was the active
|
||||
provisioning path through v1.28; from v1.29 the operator runs
|
||||
`terraform apply` in the `nova-platform-ops` checkout and `nova idp
|
||||
setup --apply` delegates to it. The CFN generation code is retained as
|
||||
read-only reference and emits a `DeprecationWarning` when the CFN
|
||||
fallback path is invoked (terraform absent from PATH).
|
||||
|
||||
```json
|
||||
{
|
||||
"Resources": {
|
||||
"NovaUsersTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-users",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
{
|
||||
"AttributeName": "user_id",
|
||||
"KeyType": "HASH"
|
||||
}
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
{
|
||||
"AttributeName": "user_id",
|
||||
"AttributeType": "S"
|
||||
},
|
||||
{
|
||||
"AttributeName": "email",
|
||||
"AttributeType": "S"
|
||||
}
|
||||
],
|
||||
"GlobalSecondaryIndexes": [
|
||||
{
|
||||
"IndexName": "email-index",
|
||||
"KeySchema": [
|
||||
{
|
||||
"AttributeName": "email",
|
||||
"KeyType": "HASH"
|
||||
}
|
||||
],
|
||||
"Projection": {
|
||||
"ProjectionType": "ALL"
|
||||
}
|
||||
}
|
||||
],
|
||||
"PointInTimeRecoverySpecification": {
|
||||
"PointInTimeRecoveryEnabled": true
|
||||
},
|
||||
"AttributeShape": {
|
||||
"user_id": "String",
|
||||
"email": "String",
|
||||
"password_hash": "String",
|
||||
"owner": "String",
|
||||
"roles": "List",
|
||||
"created_at": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaSessionsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-sessions",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
{
|
||||
"AttributeName": "session_id",
|
||||
"KeyType": "HASH"
|
||||
}
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
{
|
||||
"AttributeName": "session_id",
|
||||
"AttributeType": "S"
|
||||
},
|
||||
{
|
||||
"AttributeName": "user_id",
|
||||
"AttributeType": "S"
|
||||
}
|
||||
],
|
||||
"GlobalSecondaryIndexes": [
|
||||
{
|
||||
"IndexName": "user_id-index",
|
||||
"KeySchema": [
|
||||
{
|
||||
"AttributeName": "user_id",
|
||||
"KeyType": "HASH"
|
||||
}
|
||||
],
|
||||
"Projection": {
|
||||
"ProjectionType": "ALL"
|
||||
}
|
||||
}
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": true
|
||||
},
|
||||
"AttributeShape": {
|
||||
"session_id": "String",
|
||||
"user_id": "String",
|
||||
"expires_at": "String (epoch seconds, TTL)",
|
||||
"created_at": "String (ISO-8601)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaPasswordResetsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-password-resets",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"KeySchema": [
|
||||
{
|
||||
"AttributeName": "reset_token",
|
||||
"KeyType": "HASH"
|
||||
}
|
||||
],
|
||||
"AttributeDefinitions": [
|
||||
{
|
||||
"AttributeName": "reset_token",
|
||||
"AttributeType": "S"
|
||||
}
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": true
|
||||
},
|
||||
"AttributeShape": {
|
||||
"reset_token": "String",
|
||||
"user_id": "String",
|
||||
"expires_at": "String (epoch seconds, TTL; 15 min)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaPatsTable": {
|
||||
"Type": "AWS::DynamoDB::Table",
|
||||
"Properties": {
|
||||
"TableName": "nova-pats",
|
||||
"BillingMode": "PAY_PER_REQUEST",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"TimeToLiveSpecification": {
|
||||
"AttributeName": "expires_at",
|
||||
"Enabled": true
|
||||
},
|
||||
"AttributeShape": {
|
||||
"jti": "String (PK)",
|
||||
"sub": "String (GSI1; subject / user_id)",
|
||||
"pat_hash": "String (GSI2; SHA-256 of the PAT for lookup)",
|
||||
"status": "String (active|revoked)",
|
||||
"issued_at": "String (ISO-8601)",
|
||||
"expires_at": "String (epoch seconds, TTL)",
|
||||
"revoked_at": "String (ISO-8601, present iff status=revoked)",
|
||||
"claims": "Map (JWT claims payload)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaOidcSigningKeyAlias": {
|
||||
"Type": "AWS::KMS::Alias",
|
||||
"Properties": {
|
||||
"AliasName": "alias/nova-oidc-signing",
|
||||
"TargetKeyId": {
|
||||
"Fn::GetAtt": "NovaOidcSigningKey.Arn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaIdpAuthRole": {
|
||||
"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": "NovaIdpAuthPolicy",
|
||||
"PolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"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}:*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"dynamodb:GetItem",
|
||||
"dynamodb:PutItem",
|
||||
"dynamodb:UpdateItem",
|
||||
"dynamodb:Query",
|
||||
"dynamodb:DeleteItem"
|
||||
],
|
||||
"Resource": [
|
||||
{
|
||||
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-users"
|
||||
},
|
||||
{
|
||||
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-sessions"
|
||||
},
|
||||
{
|
||||
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-password-resets"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"NovaIdpTokenVendRole": {
|
||||
"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": "NovaIdpTokenVendPolicy",
|
||||
"PolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"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}:*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"dynamodb:GetItem",
|
||||
"dynamodb:PutItem",
|
||||
"dynamodb:UpdateItem",
|
||||
"dynamodb:Query",
|
||||
"dynamodb:DeleteItem"
|
||||
],
|
||||
"Resource": [
|
||||
{
|
||||
"Fn::Sub": "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/nova-pats"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:Sign",
|
||||
"kms:GetPublicKey",
|
||||
"kms:DescribeKey"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::GetAtt": "NovaOidcSigningKey.Arn"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"NovaIdpJwksRole": {
|
||||
"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": "NovaIdpJwksPolicy",
|
||||
"PolicyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"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}:*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:Sign",
|
||||
"kms:GetPublicKey",
|
||||
"kms:DescribeKey"
|
||||
],
|
||||
"Resource": {
|
||||
"Fn::GetAtt": "NovaOidcSigningKey.Arn"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"NovaIdpAuthFunction": {
|
||||
"Type": "AWS::Lambda::Function",
|
||||
"Properties": {
|
||||
"Handler": "nova_idp_auth.lambda_handler",
|
||||
"Runtime": "python3.12",
|
||||
"MemorySize": 512,
|
||||
"Timeout": 30,
|
||||
"Role": {
|
||||
"Fn::GetAtt": [
|
||||
"NovaIdpAuthRole",
|
||||
"Arn"
|
||||
]
|
||||
},
|
||||
"Environment": {
|
||||
"Variables": {
|
||||
"NOVA_USERS_TABLE": "nova-users",
|
||||
"NOVA_SESSIONS_TABLE": "nova-sessions",
|
||||
"NOVA_PASSWORD_RESETS_TABLE": "nova-password-resets",
|
||||
"NOVA_PATS_TABLE": "nova-pats"
|
||||
}
|
||||
},
|
||||
"Code": {
|
||||
"ZipFile": "def lambda_handler(event, context):\n return {}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaIdpTokenVendFunction": {
|
||||
"Type": "AWS::Lambda::Function",
|
||||
"Properties": {
|
||||
"Handler": "nova_idp_token_vend.lambda_handler",
|
||||
"Runtime": "python3.12",
|
||||
"MemorySize": 512,
|
||||
"Timeout": 30,
|
||||
"Role": {
|
||||
"Fn::GetAtt": [
|
||||
"NovaIdpTokenVendRole",
|
||||
"Arn"
|
||||
]
|
||||
},
|
||||
"Environment": {
|
||||
"Variables": {
|
||||
"NOVA_USERS_TABLE": "nova-users",
|
||||
"NOVA_SESSIONS_TABLE": "nova-sessions",
|
||||
"NOVA_PASSWORD_RESETS_TABLE": "nova-password-resets",
|
||||
"NOVA_PATS_TABLE": "nova-pats",
|
||||
"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"
|
||||
}
|
||||
},
|
||||
"Code": {
|
||||
"ZipFile": "def lambda_handler(event, context):\n return {}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaIdpJwksFunction": {
|
||||
"Type": "AWS::Lambda::Function",
|
||||
"Properties": {
|
||||
"Handler": "nova_idp_jwks.lambda_handler",
|
||||
"Runtime": "python3.12",
|
||||
"MemorySize": 256,
|
||||
"Timeout": 30,
|
||||
"Role": {
|
||||
"Fn::GetAtt": [
|
||||
"NovaIdpJwksRole",
|
||||
"Arn"
|
||||
]
|
||||
},
|
||||
"Environment": {
|
||||
"Variables": {
|
||||
"NOVA_OIDC_KMS_KEY_ID": "alias/nova-oidc-signing"
|
||||
}
|
||||
},
|
||||
"Code": {
|
||||
"ZipFile": "def lambda_handler(event, context):\n return {}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NovaIdpAuthUrl": {
|
||||
"Type": "AWS::Lambda::Url",
|
||||
"Properties": {
|
||||
"TargetFunction": {
|
||||
"Ref": "NovaIdpAuthFunction"
|
||||
},
|
||||
"AuthType": "AWS_IAM"
|
||||
}
|
||||
},
|
||||
"NovaIdpTokenVendUrl": {
|
||||
"Type": "AWS::Lambda::Url",
|
||||
"Properties": {
|
||||
"TargetFunction": {
|
||||
"Ref": "NovaIdpTokenVendFunction"
|
||||
},
|
||||
"AuthType": "AWS_IAM"
|
||||
}
|
||||
},
|
||||
"NovaIdpJwksUrl": {
|
||||
"Type": "AWS::Lambda::Url",
|
||||
"Properties": {
|
||||
"TargetFunction": {
|
||||
"Ref": "NovaIdpJwksFunction"
|
||||
},
|
||||
"AuthType": "NONE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
+8
-3
@@ -1,9 +1,10 @@
|
||||
"""nova idp setup --check/--apply/--verify (REQ-340, REQ-341, C-2.1, ≤50 lines)."""
|
||||
"""nova idp setup --check/--apply/--verify (REQ-340, REQ-341, REQ-369, ≤50 lines)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,8 +20,8 @@ def _load_setup():
|
||||
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("--apply", action="store_true", help="terraform apply (REQ-369; CFN fallback)")
|
||||
p.add_argument("--verify", action="store_true", help="terraform plan (REQ-369; KMS fallback)")
|
||||
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)
|
||||
@@ -31,8 +32,12 @@ def run(args) -> int:
|
||||
if args.check:
|
||||
print(json.dumps(mod.check_prerequisites(), indent=2)); return 0
|
||||
if args.verify:
|
||||
if shutil.which("terraform"):
|
||||
r = mod.terraform_plan(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
|
||||
r = mod.verify(); print(json.dumps(r, indent=2)); return 0 if r["passed"] else 1
|
||||
if args.apply or args.dry_run:
|
||||
if not args.dry_run and shutil.which("terraform"):
|
||||
r = mod.terraform_apply(); print(json.dumps(r, indent=2)); return 0 if r["deployed"] else 1
|
||||
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
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""nova idp setup terraform-delegation tests (REQ-369, spec §7.5).
|
||||
|
||||
P3 Wave 2: verifies the ``nova idp setup --apply`` / ``--verify`` paths
|
||||
delegate to ``terraform apply -auto-approve`` / ``terraform plan`` when
|
||||
``terraform`` is on PATH, and fall back to the archived CFN path
|
||||
(emitting a ``DeprecationWarning``) when terraform is absent.
|
||||
|
||||
Mirrors the importlib loading + ``mock.patch``/``monkeypatch`` style of
|
||||
``tests/test_idp_setup.py`` (``lambda`` is a Python reserved word).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
_SETUP_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_setup.py"
|
||||
setup = _load("nova_idp_setup_tf_test", _SETUP_PATH)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# core/lambda/nova_idp_setup.py — terraform_apply / terraform_plan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTerraformApply:
|
||||
def test_apply_invokes_terraform_apply_auto_approve(self, monkeypatch):
|
||||
"""terraform_apply shells out to ``terraform apply -auto-approve``."""
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
r = setup.terraform_apply()
|
||||
assert called["cmd"] == ["terraform", "apply", "-auto-approve"]
|
||||
assert r["deployed"] is True
|
||||
assert r["returncode"] == 0
|
||||
assert r["command"] == ["terraform", "apply", "-auto-approve"]
|
||||
|
||||
def test_apply_auto_approve_false_omits_flag(self, monkeypatch):
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
setup.terraform_apply(auto_approve=False)
|
||||
assert called["cmd"] == ["terraform", "apply"]
|
||||
|
||||
def test_apply_nonzero_returncode_means_not_deployed(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
setup.subprocess, "run", lambda cmd, **kw: mock.MagicMock(returncode=1)
|
||||
)
|
||||
r = setup.terraform_apply()
|
||||
assert r["deployed"] is False
|
||||
assert r["returncode"] == 1
|
||||
|
||||
|
||||
class TestTerraformPlan:
|
||||
def test_plan_invokes_terraform_plan(self, monkeypatch):
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
r = setup.terraform_plan()
|
||||
assert called["cmd"] == ["terraform", "plan"]
|
||||
assert r["passed"] is True
|
||||
assert r["command"] == ["terraform", "plan"]
|
||||
|
||||
def test_plan_nonzero_returncode_means_not_passed(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
setup.subprocess, "run", lambda cmd, **kw: mock.MagicMock(returncode=2)
|
||||
)
|
||||
r = setup.terraform_plan()
|
||||
assert r["passed"] is False
|
||||
assert r["returncode"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_and_deploy emits DeprecationWarning (CFN fallback path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCfnFallbackDeprecation:
|
||||
def test_generate_and_deploy_warns_on_cfn_path(self):
|
||||
"""The archived CFN deploy path raises DeprecationWarning (REQ-369)."""
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
with mock.patch("subprocess.check_call", return_value=0):
|
||||
r = setup.generate_and_deploy(approve_fn=lambda: True)
|
||||
assert r["deployed"] is True
|
||||
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert len(dep) == 1, f"expected one DeprecationWarning, got {dep}"
|
||||
assert "CFN path is archived" in str(dep[0].message)
|
||||
assert "docs/archive/nova-idp-cfn-v1.28.md" in str(dep[0].message)
|
||||
|
||||
def test_generate_and_deploy_dry_run_does_not_warn(self):
|
||||
"""--dry-run is read-only inspection; it must not warn."""
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
r = setup.generate_and_deploy(dry_run=True)
|
||||
assert r["deployed"] is False
|
||||
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert dep == [], f"dry-run must not emit DeprecationWarning, got {dep}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nova/idp/setup.py CLI wrapper — terraform delegation vs CFN fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cli_args(**kw):
|
||||
"""Build a MagicMock mimicking the argparse Namespace for `nova idp setup`."""
|
||||
a = mock.MagicMock()
|
||||
a.check = kw.get("check", False)
|
||||
a.apply = kw.get("apply", False)
|
||||
a.verify = kw.get("verify", False)
|
||||
a.dry_run = kw.get("dry_run", False)
|
||||
a.public_jwks_domain = kw.get("public_jwks_domain", None)
|
||||
return a
|
||||
|
||||
|
||||
class TestCliApplyDelegation:
|
||||
def test_apply_delegates_to_terraform_when_on_path(self, monkeypatch, capsys):
|
||||
"""terraform on PATH → --apply runs `terraform apply -auto-approve`."""
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/terraform" if name == "terraform" else None)
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
from nova.idp import setup as cli_setup
|
||||
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: "/usr/bin/terraform" if name == "terraform" else None)
|
||||
# Patch subprocess.run inside the loaded core module (used by terraform_apply).
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
rc = cli_setup.run(_cli_args(apply=True))
|
||||
assert rc == 0
|
||||
assert called["cmd"] == ["terraform", "apply", "-auto-approve"]
|
||||
out = capsys.readouterr().out
|
||||
assert "deployed" in out
|
||||
|
||||
def test_apply_falls_back_to_cfn_when_terraform_absent(self, monkeypatch, capsys):
|
||||
"""terraform absent → --apply falls back to the CFN path + warns."""
|
||||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||||
from nova.idp import setup as cli_setup
|
||||
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: None)
|
||||
# Stub the CFN deploy so it succeeds without touching aws CLI; answer
|
||||
# the NFR-10 y/N prompt (the CLI path has no approve_fn hook).
|
||||
monkeypatch.setattr("subprocess.check_call", return_value=0)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **kw: "y")
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
rc = cli_setup.run(_cli_args(apply=True))
|
||||
assert rc == 0
|
||||
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||
assert len(dep) == 1, f"expected DeprecationWarning on CFN fallback, got {dep}"
|
||||
assert "docs/archive/nova-idp-cfn-v1.28.md" in str(dep[0].message)
|
||||
out = capsys.readouterr().out
|
||||
assert "AWS::Lambda::Function" in out # CFN resource summary printed
|
||||
|
||||
|
||||
class TestCliVerifyDelegation:
|
||||
def test_verify_delegates_to_terraform_plan_when_on_path(self, monkeypatch, capsys):
|
||||
"""terraform on PATH → --verify runs `terraform plan`."""
|
||||
from nova.idp import setup as cli_setup
|
||||
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: "/usr/bin/terraform" if name == "terraform" else None)
|
||||
called = {}
|
||||
|
||||
def _fake_run(cmd, **kw):
|
||||
called["cmd"] = list(cmd)
|
||||
return mock.MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(setup.subprocess, "run", _fake_run)
|
||||
rc = cli_setup.run(_cli_args(verify=True))
|
||||
assert rc == 0
|
||||
assert called["cmd"] == ["terraform", "plan"]
|
||||
out = capsys.readouterr().out
|
||||
assert "passed" in out
|
||||
|
||||
def test_verify_falls_back_to_kms_roundtrip_when_terraform_absent(self, monkeypatch, capsys):
|
||||
"""terraform absent → --verify falls back to the existing KMS round-trip."""
|
||||
from nova.idp import setup as cli_setup
|
||||
monkeypatch.setattr(cli_setup.shutil, "which", lambda name: None)
|
||||
# The CLI loads core/lambda/nova_idp_setup.py into its own module
|
||||
# instance; stub _load_setup so verify() is deterministic and does
|
||||
# not require pyjwt/cryptography (the real round-trip is covered by
|
||||
# tests/test_idp_setup.py).
|
||||
fake_mod = mock.MagicMock()
|
||||
fake_mod.verify.return_value = {"passed": True, "detail": "KMS round-trip OK"}
|
||||
monkeypatch.setattr(cli_setup, "_load_setup", lambda: fake_mod)
|
||||
rc = cli_setup.run(_cli_args(verify=True))
|
||||
assert rc == 0
|
||||
fake_mod.verify.assert_called_once()
|
||||
out = capsys.readouterr().out
|
||||
assert "passed" in out # KMS round-trip result printed
|
||||
Reference in New Issue
Block a user