Files
acdl/core/pat_lifecycle.py
T
Jon Chery 14809327fb feat(P04): PAT lifecycle + nova auth login/revoke/status (REQ-342..344, C-7.3, security+cli)
---ci---
project: acdl
phase: 4
milestone: v1.28
status: execute
persona: cli-engineer
---
2026-08-19 23:11:16 +00:00

164 lines
4.6 KiB
Python

"""PAT (personal access token) lifecycle — issue + revoke (REQ-342, REQ-343).
PATs are signed JWTs (``typ: "developer_pat"``, KMS-signed) that
authenticate a developer/service-account to the token-vend Lambda. Only
the **hash** is stored in ``nova-pats`` (REQ-343) — the raw PAT is
returned to the caller once and never persisted.
## Max TTL (C-6.2)
* developer: ≤ 24h (86400s)
* service-account: ≤ 1h (3600s)
Enforced in :func:`issue_pat` via the ``subject_type`` argument.
## DynamoDB schema (REQ-343)
* PK: ``jti`` (uuid4)
* GSI1: ``sub`` (list PATs for a user)
* GSI2: ``pat_hash`` (SHA-256 of the raw PAT for lookup)
* ``status``: ``active`` | ``revoked`` (revoked PATs retained for audit)
* ``expires_at``: epoch seconds (TTL)
"""
from __future__ import annotations
import datetime
import hashlib
import json
import os
import sys
import uuid
import boto3
PATS_TABLE = os.environ.get("NOVA_PATS_TABLE", "nova-pats")
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
OIDC_ISSUER = os.environ.get("NOVA_OIDC_ISSUER", "nova-idp")
# C-6.2 max TTLs (seconds).
MAX_TTL_DEV = 24 * 3600 # 24h
MAX_TTL_SERVICE = 3600 # 1h
_dynamodb = None
def _get_dynamodb():
global _dynamodb
if _dynamodb is None:
_dynamodb = boto3.resource("dynamodb")
return _dynamodb
def _iso8601_now() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
def _epoch_now() -> int:
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
def _emit_audit(event_type: str, **fields) -> None:
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
for _k in ("pat", "raw_pat"):
payload.pop(_k, None)
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
sys.stderr.flush()
def _max_ttl(subject_type: str) -> int:
if subject_type == "service-account":
return MAX_TTL_SERVICE
return MAX_TTL_DEV
def issue_pat(
subject: str,
roles: list[str],
owner: str,
ttl_seconds: int,
key_id: str = OIDC_KMS_KEY_ID,
subject_type: str = "developer",
claims: dict | None = None,
) -> str:
"""Issue a PAT (signed JWT) + store its hash in nova-pats (REQ-342).
Args:
subject: the subject (user_id).
roles: the roles to embed in the PAT.
owner: the tenant owner.
ttl_seconds: requested TTL. Clamped to the C-6.2 max for
``subject_type`` (24h dev, 1h service-account).
key_id: KMS key ID/alias.
subject_type: ``"developer"`` or ``"service-account"``.
claims: extra claims to embed.
Returns:
The raw PAT JWT string (returned once; only the hash is stored).
"""
max_ttl = _max_ttl(subject_type)
if ttl_seconds > max_ttl:
ttl_seconds = max_ttl
if ttl_seconds < 1:
raise ValueError("ttl_seconds must be >= 1")
jti = str(uuid.uuid4())
now = _epoch_now()
exp = now + ttl_seconds
pat_claims = {
"iss": OIDC_ISSUER,
"sub": subject,
"typ": "developer_pat",
"jti": jti,
"iat": now,
"exp": exp,
"roles": roles,
"owner": owner,
}
if claims:
pat_claims.update(claims)
from core.kms_signing import sign_jwt
pat_jwt = sign_jwt(pat_claims, key_id=key_id)
# Only the hash is stored (REQ-343) — NOT the raw PAT.
pat_hash = hashlib.sha256(pat_jwt.encode("ascii")).hexdigest()
table = _get_dynamodb().Table(PATS_TABLE)
table.put_item(
TableName=PATS_TABLE,
Item={
"jti": jti,
"sub": subject,
"pat_hash": pat_hash,
"status": "active",
"issued_at": _iso8601_now(),
"expires_at": str(exp),
"subject_type": subject_type,
"claims": json.dumps(pat_claims),
},
)
_emit_audit("pat.issued", jti=jti, sub=subject, subject_type=subject_type, ttl=ttl_seconds)
return pat_jwt
def revoke_pat(jti: str) -> dict:
"""Revoke a PAT (D-229, REQ-342). Revoked PATs retained for audit.
Returns the update response. Audit ``pat.revoked`` emitted.
"""
table = _get_dynamodb().Table(PATS_TABLE)
resp = table.update_item(
TableName=PATS_TABLE,
Key={"jti": jti},
UpdateExpression="SET #s = :rev, revoked_at = :now",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":rev": "revoked", ":now": _iso8601_now()},
)
_emit_audit("pat.revoked", jti=jti)
return resp
if __name__ == "__main__": # pragma: no cover - CLI inspection helper
print("use nova/auth/login.py and nova/auth/revoke.py", file=sys.stderr)