P3: API keys, bearer auth on the admin API, whoami, generated /api/docs
A key is the person who minted it, narrowed to the scopes they chose. Only the sha256 is stored; the full key is returned once. Scopes must be a subset of the owner's capabilities at mint time and are enforced again at use time inside identity.can(), the one place that decides, so a key never outlives its owner's demotion and disabling a person disables their keys with no separate flag. A key cannot carry apikeys:own or the owner powers, so it cannot mint keys. Revoked rows stay; a foreign key id is 404, never 403. Bearer keys are honoured ONLY on /api/admin. The rest of the site reads sessions alone, so a scoped key never widens into a browser identity. X-Admin-Token remains break glass and, having no person, cannot own a key. /api/docs is generated from the router on every request: path, methods and docstring from the route objects, and the capability read out of each handler's own _auth() call so it cannot drift from the check. Gated on a new api:docs capability (leader and above). GET /api/admin/whoami answers who the API thinks you are and what you can do. Tests: smoke_identity 66 -> 92, smoke_admin 53 -> 58 (registry has a capability for every route, docs page renders every route). Driven end to end on a throwaway site with a DB copy: mint, whoami via key, scoped 200s and a 403 that names the narrowing, key-mints-key 403, garbage key 401, admin token on /keys 403, key on /account is not a session, revoke then 401, second revoke 409.
This commit is contained in:
@@ -168,6 +168,53 @@ con.close()
|
||||
for k in ("invite.created", "invite.consumed", "login.ok", "login.failed", "login.throttled"):
|
||||
check("auth_events records %s" % k, k in kinds)
|
||||
|
||||
print("\napi keys")
|
||||
raises("label required", 422, I.mint_api_key, leader, "", ["leads:read"])
|
||||
raises("scopes required", 422, I.mint_api_key, leader, "script", [])
|
||||
raises("scope the person does not hold", 422, I.mint_api_key, leader, "script", ["settings:write"])
|
||||
raises("unscopable scope refused even for an owner", 422, I.mint_api_key, owner, "script", ["apikeys:own"])
|
||||
raises("bad expiry", 422, I.mint_api_key, leader, "script", ["leads:read"], "soon")
|
||||
raises("expiry over the cap", 422, I.mint_api_key, leader, "script", ["leads:read"], 9999)
|
||||
full, row = I.mint_api_key(leader, "roundup script", ["leads:read", "nearby:write"], 30)
|
||||
check("key has the prefix and is not stored", full.startswith("gls73_") and "key_hash" not in row
|
||||
and row["prefix"] == full[:12] and row["state"] == "active" and row["expires_at"])
|
||||
check("scopes stored sorted", row["scopes"] == ["leads:read", "nearby:write"])
|
||||
kp = I.api_key_person(full)
|
||||
check("key resolves to its owner, narrowed", kp and kp["email"] == "leader@example.test"
|
||||
and kp["key_scopes"] == {"leads:read", "nearby:write"} and kp["key_prefix"] == row["prefix"])
|
||||
check("can() honours the narrowing", I.can(kp, "leads:read") and I.can(kp, "nearby:write")
|
||||
and not I.can(kp, "announcements:write") and not I.can(kp, "apikeys:own"))
|
||||
check("unit scope still applies through a key", I.can(kp, "nearby:write", pack["id"]))
|
||||
check("capabilities list reflects the key", kp["capabilities"] == ["leads:read", "nearby:write"])
|
||||
check("last_used_at touched", I.list_api_keys(leader["id"])[0]["last_used_at"])
|
||||
raises("a key cannot mint keys", 403, I.mint_api_key, kp, "nested", ["leads:read"])
|
||||
check("unknown key is nobody", I.api_key_person("gls73_nope") is None)
|
||||
check("foreign-prefixed value is nobody", I.api_key_person("tk_" + full[6:]) is None)
|
||||
check("empty is nobody", I.api_key_person("") is None and I.api_key_person(None) is None)
|
||||
check("not another person's to revoke", I.revoke_api_key(owner, row["id"]) is None)
|
||||
rev = I.revoke_api_key(leader, row["id"])
|
||||
check("revoked by its owner", rev["state"] == "revoked" and rev["revoked_at"])
|
||||
check("revoked key is nobody", I.api_key_person(full) is None)
|
||||
raises("second revoke", 409, I.revoke_api_key, leader, row["id"])
|
||||
full2, row2 = I.mint_api_key(leader, "no expiry", ["leads:read"])
|
||||
check("no expiry allowed", row2["expires_at"] is None and I.api_key_person(full2) is not None)
|
||||
con = I.connect()
|
||||
con.execute("UPDATE api_keys SET expires_at='2000-01-01T00:00:00+00:00' WHERE id=?", (row2["id"],)); con.commit(); con.close()
|
||||
check("expired key is nobody, and lists as expired", I.api_key_person(full2) is None
|
||||
and I.list_api_keys(leader["id"])[0]["state"] == "expired")
|
||||
full3, row3 = I.mint_api_key(leader, "survives?", ["leads:read"])
|
||||
con = I.connect()
|
||||
con.execute("UPDATE people SET disabled_at=? WHERE id=?", (I._now(), leader["id"])); con.commit(); con.close()
|
||||
check("disabling the person kills the key", I.api_key_person(full3) is None)
|
||||
con = I.connect()
|
||||
con.execute("UPDATE people SET disabled_at=NULL WHERE id=?", (leader["id"],)); con.commit(); con.close()
|
||||
check("scopable set for a leader excludes the unscopable", "apikeys:own" not in I.scopable_caps(leader)
|
||||
and "leads:read" in I.scopable_caps(leader) and "settings:write" not in I.scopable_caps(leader))
|
||||
con = I.connect()
|
||||
kinds = {r["kind"] for r in con.execute("SELECT DISTINCT kind FROM auth_events")}
|
||||
con.close()
|
||||
check("auth_events records mint and revoke", "apikey.minted" in kinds and "apikey.revoked" in kinds)
|
||||
|
||||
print("\nmeeting words")
|
||||
D = dict(MEETING_DAY="Tuesday", MEETING_DAYS="Tuesdays", MEETING_DAY_ABBR="Tue", PACK_TIME="6:00 PM",
|
||||
TROOP_TIME="7:30 PM", PACK_CLOCK="6:00", TROOP_CLOCK="7:30")
|
||||
|
||||
Reference in New Issue
Block a user