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