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:
m17hr1l
2026-05-23 00:46:45 +02:00
parent 4a832964a3
commit cb7bef4e40
5 changed files with 298 additions and 50 deletions

86
tests/test_adminauth.py Normal file
View 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"]