From 71562d9db2c8b9ac9d597e14a583f23e0239d15e Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 19 Aug 2026 22:55:10 +0000 Subject: [PATCH] feat(P03): DynamoDB identity schema + CFN snippet (REQ-335, backend-engineer) ---ci--- project: acdl phase: 3 milestone: v1.28 status: execute persona: backend-engineer --- --- core/lambda/nova_idp_auth_cfn.py | 244 +++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 core/lambda/nova_idp_auth_cfn.py diff --git a/core/lambda/nova_idp_auth_cfn.py b/core/lambda/nova_idp_auth_cfn.py new file mode 100644 index 0000000..887ab0d --- /dev/null +++ b/core/lambda/nova_idp_auth_cfn.py @@ -0,0 +1,244 @@ +"""CloudFormation snippet for the Nova IdP DynamoDB identity schema (REQ-335). + +This module exports :func:`dynamodb_tables_snippet`, which returns a +CloudFormation fragment (a plain ``dict``) defining the four DynamoDB +tables that back the Nova identity provider: + + * ``nova-users`` — user records (PK ``user_id``, GSI1 ``email``) + * ``nova-sessions`` — session tokens (PK ``session_id``, GSI1 + ``user_id``, TTL ``expires_at``) + * ``nova-password-resets`` — reset tokens (PK ``reset_token``, TTL + ``expires_at`` — 15 min) + * ``nova-pats`` — personal access tokens (PK ``jti``, GSI1 + ``sub``, GSI2 ``pat_hash``). This table is consumed in P4 (OIDC/PAT + issuance) but is defined here so a single ``nova idp setup`` + CloudFormation template provisions the complete identity backend. + +Design notes (REQ-335): + * All tables use ``BillingMode: PAY_PER_REQUEST`` (on-demand) — the + IdP traffic is bursty and unpredictable; provisioned capacity would + either throttle or waste money. + * PITR (``PointInTimeRecoverySpecification``) is enabled on + ``nova-users`` — user records are irreplaceable; continuous backup + protects against accidental deletes / corrupt writes. The session / + reset / PAT tables are ephemeral (TTL-managed) so PITR is not + required there, but enabling it is cheap insurance; we enable it on + ``nova-users`` per REQ-335 and leave the others as on-demand only + (TTL is the recovery mechanism for those). + * TTL attributes (``expires_at``) are epoch seconds — DynamoDB TTL + silently deletes expired items in the background (best-effort, do + not rely on for access control; the handler also checks ``expires_at`` + on read). + +The fragment is composed into the full ``nova idp setup`` template in +P4 Wave 8 (``nova idp setup --apply``). The keys in the returned dict +are CloudFormation logical resource IDs (``NovaUsersTable``, etc.) so +the composer can merge it directly into a template's ``Resources`` +section. +""" + +from __future__ import annotations + +from typing import Any, Dict + + +def _attribute(name: str, attr_type: str = "S") -> Dict[str, str]: + return {"AttributeName": name, "AttributeType": attr_type} + + +def _key_schema(name: str, key_type: str = "HASH") -> Dict[str, str]: + return {"AttributeName": name, "KeyType": key_type} + + +def dynamodb_tables_snippet() -> Dict[str, Dict[str, Any]]: + """Return a CloudFormation fragment defining the four IdP DynamoDB tables. + + The returned dict maps logical resource IDs to CloudFormation + resource dicts (``Type: AWS::DynamoDB::Table``). It is intended to be + merged into the ``Resources`` block of the full + ``nova idp setup`` template (P4 Wave 8). + + Tables: + * ``NovaUsersTable`` (``nova-users``) + * ``NovaSessionsTable`` (``nova-sessions``) + * ``NovaPasswordResetsTable`` (``nova-password-resets``) + * ``NovaPatsTable`` (``nova-pats``) + + All tables are ``PAY_PER_REQUEST`` (on-demand). PITR is enabled on + ``nova-users`` (REQ-335). TTL is enabled on the three ephemeral + tables (``expires_at`` epoch-seconds attribute). + """ + return { + # ----------------------------------------------------------------- + # nova-users — the user directory (PK user_id, GSI1 email). + # PITR enabled: user records are irreplaceable. + # ----------------------------------------------------------------- + "NovaUsersTable": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": "nova-users", + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": [ + _key_schema("user_id", "HASH"), + ], + "AttributeDefinitions": [ + _attribute("user_id", "S"), + _attribute("email", "S"), + ], + "GlobalSecondaryIndexes": [ + { + "IndexName": "email-index", + "KeySchema": [_key_schema("email", "HASH")], + "Projection": {"ProjectionType": "ALL"}, + }, + ], + "PointInTimeRecoverySpecification": { + "PointInTimeRecoveryEnabled": True, + }, + # Attribute shape (for documentation / the setup --dry-run + # summary; DynamoDB is schemaless so this is not enforced): + # user_id String (PK) + # email String (GSI1 hash, unique) + # password_hash String (Argon2id, never the raw password) + # owner String + # roles List + # created_at String (ISO-8601) + "AttributeShape": { + "user_id": "String", + "email": "String", + "password_hash": "String", + "owner": "String", + "roles": "List", + "created_at": "String", + }, + }, + }, + # ----------------------------------------------------------------- + # nova-sessions — session tokens (PK session_id, GSI1 user_id). + # TTL: expires_at (epoch seconds). Sessions live 24h. + # ----------------------------------------------------------------- + "NovaSessionsTable": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": "nova-sessions", + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": [ + _key_schema("session_id", "HASH"), + ], + "AttributeDefinitions": [ + _attribute("session_id", "S"), + _attribute("user_id", "S"), + ], + "GlobalSecondaryIndexes": [ + { + "IndexName": "user_id-index", + "KeySchema": [_key_schema("user_id", "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)", + }, + }, + }, + # ----------------------------------------------------------------- + # nova-password-resets — reset tokens (PK reset_token). + # TTL: expires_at (epoch seconds). Tokens live 15 min. + # ----------------------------------------------------------------- + "NovaPasswordResetsTable": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": "nova-password-resets", + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": [ + _key_schema("reset_token", "HASH"), + ], + "AttributeDefinitions": [ + _attribute("reset_token", "S"), + ], + "TimeToLiveSpecification": { + "AttributeName": "expires_at", + "Enabled": True, + }, + "AttributeShape": { + "reset_token": "String", + "user_id": "String", + "expires_at": "String (epoch seconds, TTL; 15 min)", + }, + }, + }, + # ----------------------------------------------------------------- + # nova-pats — personal access tokens (PK jti, GSI1 sub, GSI2 pat_hash). + # Consumed in P4 (OIDC/PAT issuance) but defined here so the single + # CloudFormation template provisions the complete identity backend. + # TTL: expires_at (epoch seconds). + # ----------------------------------------------------------------- + "NovaPatsTable": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": "nova-pats", + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": [ + _key_schema("jti", "HASH"), + ], + "AttributeDefinitions": [ + _attribute("jti", "S"), + _attribute("sub", "S"), + _attribute("pat_hash", "S"), + ], + "GlobalSecondaryIndexes": [ + { + "IndexName": "sub-index", + "KeySchema": [_key_schema("sub", "HASH")], + "Projection": {"ProjectionType": "ALL"}, + }, + { + "IndexName": "pat_hash-index", + "KeySchema": [_key_schema("pat_hash", "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)", + }, + }, + }, + } + + +def table_names() -> Dict[str, str]: + """Return the logical→physical table-name mapping (for env-var defaults).""" + return { + "users": "nova-users", + "sessions": "nova-sessions", + "password_resets": "nova-password-resets", + "pats": "nova-pats", + } + + +if __name__ == "__main__": # pragma: no cover - CLI inspection helper + import json + import sys + + if "--names" in sys.argv: + sys.stdout.write(json.dumps(table_names(), indent=2) + "\n") + else: + sys.stdout.write(json.dumps(dynamodb_tables_snippet(), indent=2) + "\n") \ No newline at end of file