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