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:
Jon Chery
2026-08-19 23:11:16 +00:00
parent 0662ed26a3
commit 14809327fb
8 changed files with 630 additions and 1 deletions
+207
View File
@@ -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"