Replaces the single shared admin secret with named per-member enrollments — no more "one key for everyone". First visit bootstraps an 'owner' slot; further members are added from inside the admin panel, each scanning their own QR. Login accepts a code matching any active member and records who got in. Offboarding is a per-member revoke: that person's codes stop immediately, everyone else is unaffected, nobody re-enrolls. Old single-secret state migrates to an 'owner' member. Admin panel gains an Access Control table (member, enrolled, last used, revoke) + add-member form that shows the new QR once. 7 tests including revocation isolation; verified the full lifecycle live (bootstrap → add → authenticate → revoke → rejected while owner persists). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
87 lines
2.9 KiB
Python
87 lines
2.9 KiB
Python
"""Admin gate — per-member TOTP enrollment + revocation isolation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pyotp
|
|
import pytest
|
|
|
|
from psyc.cockpit import adminauth
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_state(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(adminauth, "_STATE_PATH", tmp_path / "admin_auth.json")
|
|
yield tmp_path / "admin_auth.json"
|
|
|
|
|
|
def _code_for_secret(secret: str) -> str:
|
|
return pyotp.TOTP(secret).now()
|
|
|
|
|
|
def _secret_of(state_path, member_id: str) -> str:
|
|
data = json.loads(state_path.read_text())
|
|
return next(m["secret"] for m in data["members"] if m["id"] == member_id)
|
|
|
|
|
|
def test_starts_unbootstrapped(fresh_state):
|
|
assert adminauth.is_bootstrapped() is False
|
|
assert adminauth.members() == []
|
|
|
|
|
|
def test_bootstrap_promotes_pending_to_owner(fresh_state):
|
|
code = adminauth.current_code() # pending secret
|
|
assert adminauth.verify(code) == "owner"
|
|
assert adminauth.is_bootstrapped() is True
|
|
assert [m["label"] for m in adminauth.members()] == ["owner"]
|
|
|
|
|
|
def test_add_member_then_each_secret_authenticates(fresh_state):
|
|
adminauth.verify(adminauth.current_code()) # bootstrap owner
|
|
aid, _ = adminauth.add_member("alice")
|
|
bid, _ = adminauth.add_member("bob")
|
|
assert adminauth.verify(_code_for_secret(_secret_of(fresh_state, aid))) == "alice"
|
|
assert adminauth.verify(_code_for_secret(_secret_of(fresh_state, bid))) == "bob"
|
|
|
|
|
|
def test_revoke_isolates_one_member(fresh_state):
|
|
adminauth.verify(adminauth.current_code())
|
|
aid, _ = adminauth.add_member("alice")
|
|
bid, _ = adminauth.add_member("bob")
|
|
a_secret = _secret_of(fresh_state, aid)
|
|
b_secret = _secret_of(fresh_state, bid)
|
|
|
|
assert adminauth.revoke_member(aid) is True
|
|
|
|
# Alice's code no longer works…
|
|
assert adminauth.verify(_code_for_secret(a_secret)) is None
|
|
# …but Bob's still does.
|
|
assert adminauth.verify(_code_for_secret(b_secret)) == "bob"
|
|
# Alice is gone from the active roster, Bob remains.
|
|
labels = [m["label"] for m in adminauth.members()]
|
|
assert "alice" not in labels and "bob" in labels
|
|
|
|
|
|
def test_members_never_expose_secrets(fresh_state):
|
|
adminauth.verify(adminauth.current_code())
|
|
adminauth.add_member("alice")
|
|
for m in adminauth.members():
|
|
assert "secret" not in m
|
|
|
|
|
|
def test_revoke_unknown_id_is_false(fresh_state):
|
|
adminauth.verify(adminauth.current_code())
|
|
assert adminauth.revoke_member("deadbeef") is False
|
|
|
|
|
|
def test_migrates_old_single_secret_format(fresh_state):
|
|
# Simulate the pre-stage-27 state file.
|
|
old_secret = pyotp.random_base32()
|
|
fresh_state.write_text(json.dumps({
|
|
"totp_secret": old_secret, "session_secret": "s", "provisioned": True,
|
|
}))
|
|
# Old enrolled secret should authenticate as the migrated 'owner'.
|
|
assert adminauth.verify(_code_for_secret(old_secret)) == "owner"
|
|
assert [m["label"] for m in adminauth.members()] == ["owner"]
|