71562d9db2
---ci--- project: acdl phase: 3 milestone: v1.28 status: execute persona: backend-engineer ---
244 lines
10 KiB
Python
244 lines
10 KiB
Python
"""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") |