From 8e8e74ac4b030906a605bd007150df5ede609dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 07:01:49 -0400 Subject: [PATCH] whoami returns enough to attribute and scope, and CAPS gains finance scout-finance validates its session against this API and needs four things whoami did not return. Without them it reports itself down rather than degrading, which is correct and also useless. - id, so a finance row can carry entered_by. The email is display-facing and is the wrong thing to write rows against. - preferred_name / full_name, for entered_by_name, captured at write time so a historical report carries the name as of that date. - global_capabilities, separate from the union. The union answers "may they see this screen"; the site-wide set answers "does this grant reach a unit they hold no membership in". For an admin who is also a den leader those are not the same, and collapsing them lets a pack-only grant travel to the troop. - memberships[].capabilities, so a separate service scopes per unit without keeping a second copy of CAPS. Nothing outside this file may map a role to a capability. Built in identity.whoami_payload() rather than in the route, so it is testable with no HTTP and the capability map stays in one place. An API key narrows the per-membership sets too, so a key can never appear to hold what can() would refuse. CAPS: finance:read and finance:write on leader, because a treasurer is a leader and leader-wide read is a deliberate design decision in finance.md. finance:admin on admin only, for categories, accounts and finance settings, which are site-wide. The break-glass token path keeps the same shape with a null id and no memberships. It has no person behind it, so nothing it did could be attributed; scout-finance refuses it outright. Additive throughout. scout-control reads none of these fields. smoke_identity 138, smoke_admin 164, smoke_documents 24. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AHy2gB4QvKmRurfXwYbwCB --- app/admin_api.py | 11 +++++--- app/identity.py | 60 +++++++++++++++++++++++++++++++++++++++++ tests/smoke_identity.py | 31 +++++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/app/admin_api.py b/app/admin_api.py index efa4bb3..70d9254 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -490,15 +490,18 @@ def whoami(request: Request, x_admin_token: str = Header(None)): and identity.get_setting("api_keys_from") == "lan": raise HTTPException(403, "API keys may only be used from the LAN right now " "(site setting api_keys_from)") - return {"email": person["email"], "global_role": person.get("global_role"), - "capabilities": person["capabilities"], "lan": client_is_lan(request), - "via": ("key " + person["key_prefix"]) if person.get("key_scopes") is not None else "session"} + via = ("key " + person["key_prefix"]) if person.get("key_scopes") is not None else "session" + return identity.whoami_payload(person, lan=client_is_lan(request), via=via) if not ADMIN_TOKEN: raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN") if x_admin_token and hmac.compare_digest(x_admin_token, ADMIN_TOKEN): if not client_is_lan(request): raise HTTPException(403, "the admin token is LAN-only") - return {"email": None, "via": "admin-token", "capabilities": ["*"], "lan": True} + # Break glass has no person behind it, so id is null and there are no + # memberships. A caller that needs to attribute a write must refuse + # this, and scout-finance does. + return {"id": None, "email": None, "via": "admin-token", "capabilities": ["*"], + "global_capabilities": ["*"], "memberships": [], "lan": True} raise HTTPException(401, "sign in, send a Bearer key, or a valid X-Admin-Token") diff --git a/app/identity.py b/app/identity.py index 4e88a69..6993d43 100644 --- a/app/identity.py +++ b/app/identity.py @@ -82,6 +82,11 @@ CAPS = { "apikeys:own", "api:docs", "email:draft", + # scout-finance. Leader-wide read is a design decision there, not an + # oversight: visible-by-default numbers are the point. Write too, + # because a treasurer is a leader and there is no treasurer role. + "finance:read", + "finance:write", }, "admin": { "account:self", @@ -103,6 +108,11 @@ CAPS = { "people:invite_admin", "email:draft", "email:send", + "finance:read", + "finance:write", + # Categories, accounts and finance settings. Site-wide, because a + # category added by one unit changes the other's vocabulary. + "finance:admin", }, } CAPS["owner"] = CAPS["admin"] | {"people:manage", "secrets:rotate"} @@ -519,6 +529,56 @@ def effective_caps(person): return caps +def caps_for(role, person=None): + """The capability set a role grants, as a sorted list. + + Narrowed to the key's scopes when `person` was reached through an API key, + so a key never appears to hold something can() would refuse. Reads the one + CAPS dictionary; nothing else may keep a second copy. + """ + caps = set(CAPS.get(role or "", set())) + scopes = (person or {}).get("key_scopes") + if scopes is not None: + caps &= scopes + return sorted(caps) + + +def whoami_payload(person, lan=False, via="session"): + """What /api/admin/whoami returns for a real person. + + Built here rather than in the route so it is testable with no HTTP, and so + the capability map stays in one file. + + `capabilities` is the union, which answers "may they see this screen at + all". `global_capabilities` is separately what their SITE-WIDE role grants, + which answers "does this reach a unit they hold no membership in" - the + two are not the same for an admin who is also a den leader, and collapsing + them would let a unit-only grant travel. Each membership carries its own + set so a separate service can scope per unit without copying CAPS. + """ + return { + "id": person["id"], + "email": person.get("email"), + "preferred_name": person.get("preferred_name"), + "full_name": person.get("full_name"), + "global_role": person.get("global_role"), + "capabilities": person["capabilities"], + "global_capabilities": caps_for(person.get("global_role"), person), + "memberships": [{ + "unit_id": m["unit_id"], + "slug": m.get("slug"), + "short_name": m.get("short_name"), + "display_name": m.get("display_name"), + "unit_type": m.get("unit_type"), + "role": m["role"], + "title": m.get("title"), + "capabilities": caps_for(m["role"], person), + } for m in person.get("memberships", [])], + "lan": bool(lan), + "via": via, + } + + def can(person, capability, unit_id=None): """Does this person hold `capability`, optionally within a specific unit? diff --git a/tests/smoke_identity.py b/tests/smoke_identity.py index bf9666d..f2fad96 100644 --- a/tests/smoke_identity.py +++ b/tests/smoke_identity.py @@ -293,6 +293,37 @@ check("no rows at all is the defaults", I.meeting_words([], D) == D) check("defaults dict is not mutated", D["PACK_TIME"] == "6:00 PM") check("live seed rows reproduce the constants", I.meeting_words(I.list_units(), D) == D) +print("\nwhoami payload (what scout-finance reads)") +o = I.get_person(owner["id"]) +l = I.get_person(leader["id"]) +wo = I.whoami_payload(o) +wl = I.whoami_payload(l) +check("carries the person id, for entered_by", wo["id"] == owner["id"]) +check("carries a display name, for entered_by_name", + wl["preferred_name"] or wl["full_name"]) +check("finance:read and finance:write are a leader capability", + "finance:read" in wl["capabilities"] and "finance:write" in wl["capabilities"]) +check("finance:admin is not", "finance:admin" not in wl["capabilities"]) +check("finance:admin is an owner capability", I.can(o, "finance:admin")) +check("a leader's membership carries its own capability set", + "finance:write" in wl["memberships"][0]["capabilities"]) +check("a leader has no site-wide grant", wl["global_capabilities"] == []) +check("an owner's site-wide grant is not empty", + "finance:write" in wo["global_capabilities"]) +check("membership rows name the unit for a screen", + wl["memberships"][0]["unit_id"] and wl["memberships"][0]["role"] == "leader") +check("payload capabilities agree with can()", + all(I.can(l, c) for c in wl["capabilities"])) +check("a capability held only per unit does not appear site-wide for others", + not I.can(l, "finance:write", troop["id"])) +rawkey, _krow = I.mint_api_key(l, "finance reader", ["finance:read"]) +kp = I.api_key_person(rawkey) +wk = I.whoami_payload(kp, via="key") +check("a key's membership capabilities are narrowed to its scopes too", + wk["memberships"][0]["capabilities"] == ["finance:read"]) +check("a key cannot appear to hold what can() would refuse", + not I.can(kp, "finance:write") and "finance:write" not in wk["capabilities"]) + print("\nsafe_next") check("relative path passes", I.safe_next("/leaders/") == "/leaders/") check("query string kept", I.safe_next("/leaders/nearby?x=1") == "/leaders/nearby?x=1")