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.
|
``_DeniedError`` (→ 403) on revocation / ABAC denial.
|
||||||
"""
|
"""
|
||||||
requested_claims = requested_claims or ["sub", "roles"]
|
requested_claims = requested_claims or ["sub", "roles"]
|
||||||
target_resource = target_resource or {"type": "contract", "id": "*", "owner": "*", "environment": environment or "dev"}
|
|
||||||
environment = environment or "dev"
|
environment = environment or "dev"
|
||||||
|
|
||||||
# 1. Decode the PAT/session (without verifying — D-229).
|
# 1. Decode the PAT/session (without verifying — D-229).
|
||||||
pat_claims = _extract_pat_claims(token)
|
pat_claims = _extract_pat_claims(token)
|
||||||
jti = pat_claims["jti"]
|
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).
|
# 2. Revocation check (D-229, strong read).
|
||||||
active, reason = _check_pat_active(jti)
|
active, reason = _check_pat_active(jti)
|
||||||
if not active:
|
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)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""nova auth — login / revoke / status subcommands (REQ-344, C-7.3).
|
||||||
|
|
||||||
|
Subpackage entry point: ``add_parser`` registers the ``auth`` subparser
|
||||||
|
with ``login``/``revoke``/``status`` sub-subcommands, each delegating to
|
||||||
|
its module's ``run``. Discovered by ``nova/cli.py`` via
|
||||||
|
``pkgutil.iter_modules`` (this package's ``add_parser`` is the hook).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("auth", help="Nova IdP auth (login/revoke/status)")
|
||||||
|
sub = p.add_subparsers(dest="auth_command", required=True)
|
||||||
|
from nova.auth import login as _login, revoke as _revoke, status as _status
|
||||||
|
_login.add_parser(sub)
|
||||||
|
_revoke.add_parser(sub)
|
||||||
|
_status.add_parser(sub)
|
||||||
|
return p
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""nova auth login — session/PAT → OIDC token, store locally (REQ-344, C-7.3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from core.auth_store import store_credential, credentials_path
|
||||||
|
|
||||||
|
|
||||||
|
def _vend(pat: str, env: str, endpoint: str) -> dict:
|
||||||
|
"""Call the token-vend Lambda (locally or via the function URL)."""
|
||||||
|
if endpoint and endpoint.startswith("http"):
|
||||||
|
import urllib.request
|
||||||
|
body = json.dumps({"token": pat, "environment": env}).encode()
|
||||||
|
req = urllib.request.Request(endpoint, data=body, headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
# Local: invoke the Lambda in-process.
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
p = Path(__import__("core").__file__).parent / "lambda" / "nova_idp_token_vend.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("nova_idp_token_vend", p)
|
||||||
|
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||||
|
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||||
|
resp = mod.lambda_handler({"body": json.dumps({"token": pat, "environment": env})}, None)
|
||||||
|
return json.loads(resp["body"])
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("login", help="exchange a PAT/session for an OIDC token")
|
||||||
|
p.add_argument("--pat", default=None, help="PAT JWT (prompted if absent)")
|
||||||
|
p.add_argument("--session", default=None, help="session token (alias for --pat)")
|
||||||
|
p.add_argument("--environment", default="dev", help="target environment")
|
||||||
|
p.add_argument("--endpoint", default=os.environ.get("NOVA_TOKEN_VEND_URL", ""),
|
||||||
|
help="token-vend function URL (empty = local)")
|
||||||
|
p.set_defaults(_run=run)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args) -> int:
|
||||||
|
pat = args.pat or args.session or os.environ.get("NOVA_PAT")
|
||||||
|
if not pat:
|
||||||
|
pat = sys.stdin.readline().strip()
|
||||||
|
if not pat:
|
||||||
|
print("error: no PAT/session provided", file=sys.stderr); return 1
|
||||||
|
result = _vend(pat, args.environment, args.endpoint)
|
||||||
|
if "token" not in result:
|
||||||
|
print(f"error: {result.get('error', result)}", file=sys.stderr); return 2
|
||||||
|
import base64
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(result["token"].split(".")[1] + "=="))
|
||||||
|
store_credential(
|
||||||
|
jti=payload.get("jti", ""), cred_type=payload.get("typ", "nova_oidc_token"),
|
||||||
|
exp=payload.get("exp", 0), oidc_token=result["token"],
|
||||||
|
)
|
||||||
|
print(f"logged in: jti={payload.get('jti')} exp={payload.get('exp')} "
|
||||||
|
f"file={credentials_path()}")
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""nova auth revoke --pat <jti> — revoke a PAT (REQ-344, D-229)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from core.auth_store import emit_revoke_audit
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("revoke", help="revoke a PAT by jti")
|
||||||
|
p.add_argument("--pat", required=True, help="PAT jti to revoke")
|
||||||
|
p.add_argument("--endpoint", default=os.environ.get("NOVA_TOKEN_VEND_URL", ""),
|
||||||
|
help="token-vend function URL (empty = local DDB)")
|
||||||
|
p.set_defaults(_run=run)
|
||||||
|
|
||||||
|
|
||||||
|
def _revoke_remote(jti: str, endpoint: str) -> dict:
|
||||||
|
import json, urllib.request
|
||||||
|
body = json.dumps({"action": "revoke_pat", "jti": jti}).encode()
|
||||||
|
req = urllib.request.Request(endpoint, data=body, headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def run(args) -> int:
|
||||||
|
try:
|
||||||
|
if args.endpoint and args.endpoint.startswith("http"):
|
||||||
|
_revoke_remote(args.pat, args.endpoint)
|
||||||
|
else:
|
||||||
|
from core.pat_lifecycle import revoke_pat
|
||||||
|
revoke_pat(args.pat)
|
||||||
|
emit_revoke_audit(args.pat)
|
||||||
|
print(f"revoked: jti={args.pat}")
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr); return 2
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""nova auth status — print active credential + mode (REQ-344)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from core.auth_store import active_credential, emit_status_audit
|
||||||
|
from core.mode_resolver import resolve_mode_from_env
|
||||||
|
|
||||||
|
|
||||||
|
def add_parser(subparsers):
|
||||||
|
p = subparsers.add_parser("status", help="show active credential + client mode")
|
||||||
|
p.set_defaults(_run=run)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args) -> int:
|
||||||
|
cred = active_credential()
|
||||||
|
emit_status_audit()
|
||||||
|
mode, reason = resolve_mode_from_env(
|
||||||
|
credential_type=cred.get("type") if cred else None,
|
||||||
|
)
|
||||||
|
if cred is None:
|
||||||
|
print(f"no active credential (mode={mode}, reason={reason})")
|
||||||
|
return 0
|
||||||
|
print(json.dumps({
|
||||||
|
"active_credential_jti": cred.get("jti"),
|
||||||
|
"type": cred.get("type"),
|
||||||
|
"exp": cred.get("exp"),
|
||||||
|
"mode": mode,
|
||||||
|
"selection_reason": reason,
|
||||||
|
}, indent=2))
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""nova auth login/revoke/status tests (REQ-344, C-7.3).
|
||||||
|
|
||||||
|
C-7.3: ``~/.nova/credentials.json`` stores OIDC token + PAT metadata
|
||||||
|
(jti, exp, type) ONLY — NOT the raw PAT. Verified by asserting the
|
||||||
|
file contains no ``raw_pat`` / ``pat`` field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
|
os.environ.setdefault("AWS_ACCESS_KEY_ID", "test")
|
||||||
|
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from moto import mock_aws
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
|
||||||
|
import core.kms_signing as kms_signing
|
||||||
|
import core.pat_lifecycle as pat_life
|
||||||
|
import core.auth_store as auth_store
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test keypair + mock KMS.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _MockKms:
|
||||||
|
def __init__(self, priv, pub_der):
|
||||||
|
self._priv = priv
|
||||||
|
self._pub_der = pub_der
|
||||||
|
|
||||||
|
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||||
|
return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||||
|
|
||||||
|
def get_public_key(self, KeyId):
|
||||||
|
return {"PublicKey": self._pub_der}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_keypair():
|
||||||
|
priv = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
pub = priv.public_key()
|
||||||
|
pub_der = pub.public_bytes(
|
||||||
|
encoding=serialization.Encoding.DER,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
return priv, pub, pub_der
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pats_table(ddb):
|
||||||
|
ddb.create_table(
|
||||||
|
TableName="nova-pats",
|
||||||
|
KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}],
|
||||||
|
AttributeDefinitions=[
|
||||||
|
{"AttributeName": "jti", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "sub", "AttributeType": "S"},
|
||||||
|
{"AttributeName": "pat_hash", "AttributeType": "S"},
|
||||||
|
],
|
||||||
|
GlobalSecondaryIndexes=[
|
||||||
|
{"IndexName": "sub-index", "KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||||
|
{"IndexName": "pat_hash-index", "KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
|
||||||
|
],
|
||||||
|
BillingMode="PAY_PER_REQUEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _cred_file(tmp_path, monkeypatch):
|
||||||
|
"""Isolate credentials.json to a tmp path."""
|
||||||
|
cred = tmp_path / "credentials.json"
|
||||||
|
monkeypatch.setenv("NOVA_CREDENTIALS_FILE", str(cred))
|
||||||
|
yield cred
|
||||||
|
kms_signing.set_kms_client_for_testing(None)
|
||||||
|
pat_life._dynamodb = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# nova auth login
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_login_stores_oidc_token_not_raw_pat(tmp_path, test_keypair, _cred_file):
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
# Issue a PAT.
|
||||||
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||||
|
assert pat # raw PAT returned once
|
||||||
|
# Run nova auth login via the local Lambda path.
|
||||||
|
from nova.auth import login as login_mod
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.pat = pat
|
||||||
|
args.session = None
|
||||||
|
args.environment = "dev"
|
||||||
|
args.endpoint = "" # local
|
||||||
|
rc = login_mod.run(args)
|
||||||
|
assert rc == 0
|
||||||
|
# Assert credentials.json exists + is 0600.
|
||||||
|
assert _cred_file.exists()
|
||||||
|
mode = stat.S_IMODE(os.stat(_cred_file).st_mode)
|
||||||
|
assert mode == 0o600
|
||||||
|
data = json.loads(_cred_file.read_text())
|
||||||
|
# C-7.3: contains the OIDC token + metadata, NOT the raw PAT.
|
||||||
|
cred = data["credentials"][0]
|
||||||
|
assert "token" in cred # the OIDC token
|
||||||
|
assert cred["type"] == "nova_oidc_token"
|
||||||
|
assert "jti" in cred and "exp" in cred
|
||||||
|
raw = _cred_file.read_text()
|
||||||
|
assert "raw_pat" not in raw
|
||||||
|
assert pat not in raw # the raw PAT string must NOT appear
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_login_denied_pat_returns_error(tmp_path, test_keypair, _cred_file):
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||||
|
# Revoke it.
|
||||||
|
# Extract jti from the PAT.
|
||||||
|
import base64
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
|
||||||
|
pat_life.revoke_pat(payload["jti"])
|
||||||
|
from nova.auth import login as login_mod
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = ""
|
||||||
|
rc = login_mod.run(args)
|
||||||
|
assert rc != 0 # denied
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# nova auth status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_no_credential(_cred_file, capsys):
|
||||||
|
from nova.auth import status as status_mod
|
||||||
|
rc = status_mod.run(mock.MagicMock())
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "no active credential" in out
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_status_shows_mode_and_jti(tmp_path, test_keypair, _cred_file, capsys):
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||||
|
from nova.auth import login as login_mod
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = ""
|
||||||
|
login_mod.run(args)
|
||||||
|
capsys.readouterr() # drain login output
|
||||||
|
from nova.auth import status as status_mod
|
||||||
|
rc = status_mod.run(mock.MagicMock())
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
data = json.loads(out)
|
||||||
|
assert "mode" in data
|
||||||
|
assert "selection_reason" in data
|
||||||
|
assert data["type"] == "nova_oidc_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# nova auth revoke
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mock_aws
|
||||||
|
def test_revoke_sets_status_revoked(tmp_path, test_keypair, _cred_file, capsys):
|
||||||
|
priv, _pub, pub_der = test_keypair
|
||||||
|
ddb = boto3.client("dynamodb", region_name="us-east-1")
|
||||||
|
_create_pats_table(ddb)
|
||||||
|
kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der))
|
||||||
|
pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600)
|
||||||
|
import base64
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "=="))
|
||||||
|
jti = payload["jti"]
|
||||||
|
from nova.auth import revoke as revoke_mod
|
||||||
|
args = mock.MagicMock()
|
||||||
|
args.pat = jti; args.endpoint = ""
|
||||||
|
rc = revoke_mod.run(args)
|
||||||
|
assert rc == 0
|
||||||
|
# Verify status=revoked in DDB.
|
||||||
|
item = ddb.get_item(TableName="nova-pats", Key={"jti": {"S": jti}}, ConsistentRead=True)
|
||||||
|
assert item["Item"]["status"]["S"] == "revoked"
|
||||||
Reference in New Issue
Block a user