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:
@@ -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?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user