stage-27: per-member TOTP enrollment + individual revocation
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>
This commit is contained in:
86
tests/test_adminauth.py
Normal file
86
tests/test_adminauth.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user