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 ---
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""Nova credential store — ``~/.nova/credentials.json`` (C-7.3, REQ-344).
|
||||
|
||||
Stores the OIDC token + PAT metadata (jti, exp, type) ONLY — **NOT the
|
||||
raw PAT** (C-7.3). The file is 0600. "Most recent wins" (D-226 Q5):
|
||||
``active_credential_jti`` points at the most-recently-stored credential.
|
||||
|
||||
Shape::
|
||||
|
||||
{
|
||||
"active_credential_jti": "<jti>",
|
||||
"credentials": [
|
||||
{"jti": ..., "type": "developer_pat"|"nova_oidc_token",
|
||||
"exp": <epoch>, "token": "<oidc jwt>", "stored_at": <epoch>}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def credentials_path() -> Path:
|
||||
return Path(os.environ.get("NOVA_CREDENTIALS_FILE")
|
||||
or os.path.expanduser("~/.nova/credentials.json"))
|
||||
|
||||
|
||||
def _emit_audit(event_type: str, **fields) -> None:
|
||||
payload = {"event": event_type, **fields}
|
||||
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def store_credential(
|
||||
jti: str,
|
||||
cred_type: str,
|
||||
exp: int,
|
||||
oidc_token: str,
|
||||
path: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Store an OIDC token + PAT metadata (NOT the raw PAT, C-7.3). 0600."""
|
||||
p = path or credentials_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {"active_credential_jti": jti, "credentials": []}
|
||||
if p.exists():
|
||||
try:
|
||||
data = json.loads(p.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {"active_credential_jti": jti, "credentials": []}
|
||||
creds = data.get("credentials", []) or []
|
||||
# Replace any existing entry with the same jti.
|
||||
creds = [c for c in creds if c.get("jti") != jti]
|
||||
import time
|
||||
creds.append({
|
||||
"jti": jti, "type": cred_type, "exp": exp,
|
||||
"token": oidc_token, "stored_at": int(time.time()),
|
||||
})
|
||||
data["credentials"] = creds
|
||||
data["active_credential_jti"] = jti
|
||||
p.write_text(json.dumps(data, indent=2, sort_keys=True))
|
||||
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
||||
_emit_audit("auth.login", jti=jti, type=cred_type)
|
||||
|
||||
|
||||
def load_credentials(path: Optional[Path] = None) -> dict:
|
||||
"""Load the credentials file (or ``{}`` if absent)."""
|
||||
p = path or credentials_path()
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def active_credential(path: Optional[Path] = None) -> Optional[dict]:
|
||||
"""Return the active credential dict (or ``None``)."""
|
||||
data = load_credentials(path)
|
||||
active_jti = data.get("active_credential_jti")
|
||||
for c in data.get("credentials", []) or []:
|
||||
if c.get("jti") == active_jti:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def emit_status_audit(path: Optional[Path] = None) -> dict:
|
||||
"""Emit ``auth.status`` audit + return the credentials data."""
|
||||
data = load_credentials(path)
|
||||
_emit_audit("auth.status", active_jti=data.get("active_credential_jti"))
|
||||
return data
|
||||
|
||||
|
||||
def emit_revoke_audit(jti: str) -> None:
|
||||
_emit_audit("auth.revoke", jti=jti)
|
||||
@@ -238,13 +238,23 @@ def vend_token(
|
||||
``_DeniedError`` (→ 403) on revocation / ABAC denial.
|
||||
"""
|
||||
requested_claims = requested_claims or ["sub", "roles"]
|
||||
target_resource = target_resource or {"type": "contract", "id": "*", "owner": "*", "environment": environment or "dev"}
|
||||
environment = environment or "dev"
|
||||
|
||||
# 1. Decode the PAT/session (without verifying — D-229).
|
||||
pat_claims = _extract_pat_claims(token)
|
||||
jti = pat_claims["jti"]
|
||||
|
||||
# Default target_resource: owner inherits from the PAT subject so
|
||||
# the owner-matches ABAC rule passes for same-tenant vends. Callers
|
||||
# can override with an explicit target_resource.
|
||||
if target_resource is None:
|
||||
target_resource = {
|
||||
"type": "contract",
|
||||
"id": "*",
|
||||
"owner": pat_claims.get("owner", "*"),
|
||||
"environment": environment,
|
||||
}
|
||||
|
||||
# 2. Revocation check (D-229, strong read).
|
||||
active, reason = _check_pat_active(jti)
|
||||
if not active:
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user