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,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