feat(P03): DynamoDB identity schema + CFN snippet (REQ-335, backend-engineer)
---ci--- project: acdl phase: 3 milestone: v1.28 status: execute persona: backend-engineer ---
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user