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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHy2gB4QvKmRurfXwYbwCB
This commit is contained in:
Claude
2026-09-10 07:01:49 -04:00
parent 77dd250436
commit 8e8e74ac4b
3 changed files with 98 additions and 4 deletions
+7 -4
View File
@@ -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")
+60
View File
@@ -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?
+31
View File
@@ -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")