14809327fb
---ci--- project: acdl phase: 4 milestone: v1.28 status: execute persona: cli-engineer ---
164 lines
4.6 KiB
Python
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) |