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:
2026-09-04 17:31:05 -04:00
parent 0b337f1f19
commit 4b5b2a3667
5 changed files with 405 additions and 2 deletions
+170
View File
@@ -78,6 +78,7 @@ CAPS = {
"calendar:write",
"unit:write_own",
"apikeys:own",
"api:docs",
"email:draft",
},
"admin": {
@@ -91,6 +92,7 @@ CAPS = {
"units:write",
"settings:write",
"apikeys:own",
"api:docs",
"people:invite_leader",
"people:invite_admin",
"email:draft",
@@ -191,6 +193,25 @@ CREATE TABLE IF NOT EXISTS settings (
updated_at TEXT NOT NULL,
updated_by TEXT
);
-- API keys (P3). One row per key; only the sha256 of the key is stored, and
-- the visible prefix exists so a person can tell their keys apart. Scopes are
-- a JSON list and are a SUBSET of the owner's capabilities, checked at mint
-- time here and again at use time in can(), so a key never outlives its
-- owner's demotion. Revoked rows stay.
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
person_id TEXT NOT NULL REFERENCES people(id),
label TEXT NOT NULL,
prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
expires_at TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_keys_person ON api_keys(person_id, revoked_at);
"""
# Seeded at boot, idempotent by slug. Values lifted from the site constants in
@@ -485,6 +506,12 @@ def can(person, capability, unit_id=None):
"""
if not person or person.get("disabled_at"):
return False
# A person reached through an API key is that person narrowed to the
# key's scopes. Checked here, in the one place that decides, so a route
# cannot forget it and a demoted owner's key loses what the owner lost.
scopes = person.get("key_scopes")
if scopes is not None and capability not in scopes:
return False
if person.get("global_role") and capability in CAPS.get(person["global_role"], set()):
return True
for m in person.get("memberships", []):
@@ -909,3 +936,146 @@ def set_setting(key, value, actor=None):
finally:
con.close()
return [s for s in all_settings() if s["key"] == key][0]
# ---------------------------------------------------------------------------
# API keys (P3)
# ---------------------------------------------------------------------------
#
# For scripts, not for people signing in every Tuesday. A key is the person
# who minted it, narrowed to the scopes they chose. It cannot hold more than
# they hold, it cannot mint further keys, and it dies with them: disabling a
# person disables their keys through can(), with no separate flag to forget.
KEY_PREFIX = "gls73_"
KEY_PREFIX_SHOWN = len(KEY_PREFIX) + 6 # "gls73_ab12cd" - enough to tell keys apart
KEY_MAX_DAYS = 365
# Scopes a key may never carry, whatever the owner holds. Minting keys from a
# key is a loop; the rest are owner powers that belong to a person at a screen.
KEY_UNSCOPABLE = {"account:self", "apikeys:own", "api:docs", "people:manage", "secrets:rotate",
"people:invite_leader", "people:invite_admin"}
def scopable_caps(person):
"""The scopes this person may put on a key: what they hold, minus the
ones a key may never carry."""
return sorted(effective_caps(person) - KEY_UNSCOPABLE)
def _key_row(r):
d = dict(r)
d.pop("key_hash", None)
d["scopes"] = json.loads(d["scopes"]) if isinstance(d.get("scopes"), str) else (d.get("scopes") or [])
now = _now()
if d.get("revoked_at"):
d["state"] = "revoked"
elif d.get("expires_at") and d["expires_at"] <= now:
d["state"] = "expired"
else:
d["state"] = "active"
return d
def mint_api_key(person, label, scopes, expires_days=None):
"""Create a key for `person`. Returns (full_key, row). The full key is
returned exactly once and never stored."""
if not person or person.get("disabled_at"):
raise IdentityError(403, "keys belong to a signed-in, enabled person")
if person.get("key_scopes") is not None:
raise IdentityError(403, "a key cannot mint keys")
label = (label or "").strip()
if not label or len(label) > 60:
raise IdentityError(422, "label is required, up to 60 characters")
if not isinstance(scopes, (list, tuple, set)) or not scopes:
raise IdentityError(422, "scopes is required: a non-empty list")
scopes = sorted({str(s).strip() for s in scopes if str(s).strip()})
allowed = set(scopable_caps(person))
bad = [s for s in scopes if s not in allowed]
if bad:
raise IdentityError(422, "scopes not available to you or not allowed on a key: %s. "
"Choose from %s" % (", ".join(bad), ", ".join(sorted(allowed))))
expires_at = None
if expires_days not in (None, ""):
try:
days = int(expires_days)
except (TypeError, ValueError):
raise IdentityError(422, "expires_days must be a whole number of days")
if not 1 <= days <= KEY_MAX_DAYS:
raise IdentityError(422, "expires_days must be 1 to %d" % KEY_MAX_DAYS)
expires_at = (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(days=days)).isoformat(timespec="seconds")
full = KEY_PREFIX + secrets.token_urlsafe(32)
kid = str(uuid.uuid4())
con = connect()
try:
con.execute(
"INSERT INTO api_keys (id, person_id, label, prefix, key_hash, scopes, created_at,"
" last_used_at, expires_at, revoked_at) VALUES (?,?,?,?,?,?,?,NULL,?,NULL)",
(kid, person["id"], label, full[:KEY_PREFIX_SHOWN], _hash_token(full),
json.dumps(scopes), _now(), expires_at))
log_event("apikey.minted", person_id=person["id"], actor_id=person["id"],
email=person.get("email"), detail="%s [%s] %s" % (label, ", ".join(scopes), kid), con=con)
con.commit()
r = con.execute("SELECT * FROM api_keys WHERE id=?", (kid,)).fetchone()
finally:
con.close()
return full, _key_row(r)
def list_api_keys(person_id):
con = connect()
try:
return [_key_row(r) for r in con.execute(
"SELECT * FROM api_keys WHERE person_id=? ORDER BY created_at DESC", (person_id,))]
finally:
con.close()
def revoke_api_key(person, key_id):
"""Revoke one of the person's OWN keys. None if it is not theirs (the
caller answers 404, never 403 - a foreign key id is not confirmed)."""
con = connect()
try:
r = con.execute("SELECT * FROM api_keys WHERE id=? AND person_id=?",
(key_id, person["id"])).fetchone()
if not r:
return None
if r["revoked_at"]:
raise IdentityError(409, "already revoked")
con.execute("UPDATE api_keys SET revoked_at=? WHERE id=?", (_now(), key_id))
log_event("apikey.revoked", person_id=person["id"], actor_id=person["id"],
email=person.get("email"), detail="%s %s" % (r["label"], key_id), con=con)
con.commit()
return _key_row(con.execute("SELECT * FROM api_keys WHERE id=?", (key_id,)).fetchone())
finally:
con.close()
def api_key_person(bearer):
"""The person behind an Authorization: Bearer value, narrowed to the key's
scopes, or None. Touches last_used_at. Never raises: an unknown, revoked,
expired or foreign-prefixed value is simply not a person."""
if not bearer or not str(bearer).startswith(KEY_PREFIX):
return None
con = connect()
try:
r = con.execute("SELECT * FROM api_keys WHERE key_hash=? AND revoked_at IS NULL",
(_hash_token(str(bearer)),)).fetchone()
if not r:
return None
now = _now()
if r["expires_at"] and r["expires_at"] <= now:
return None
p = _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (r["person_id"],)).fetchone())
if not p or p.get("disabled_at"):
return None
con.execute("UPDATE api_keys SET last_used_at=? WHERE id=?", (now, r["id"]))
con.commit()
p["key_scopes"] = set(json.loads(r["scopes"]))
p["key_id"] = r["id"]
p["key_prefix"] = r["prefix"]
p["capabilities"] = sorted(effective_caps(p) & p["key_scopes"])
return p
finally:
con.close()