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