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
+172 -2
View File
@@ -38,9 +38,13 @@ one CAPS dictionary - never by a role comparison here.
""" """
import hmac import hmac
import html as _html
import inspect
import os import os
import re
from fastapi import APIRouter, Body, Header, HTTPException, Query, Request from fastapi import APIRouter, Body, Header, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
import auth import auth
import identity import identity
@@ -50,6 +54,26 @@ ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip()
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
# /api/docs sits beside /api/admin, not under it: it describes the admin API
# and is gated like it, but a docs URL under the admin prefix would be one
# more path the LAN-only NPM block has to reason about.
docs_router = APIRouter(prefix="/api", tags=["docs"])
def _person(request):
"""Who is calling: a session first, then an API key, then nobody.
Keys are honoured ONLY here, on the admin API. The rest of the site
(documents, account) is for people at a screen and reads sessions alone,
so a scoped key never widens into a browser identity."""
p = auth.current_person(request)
if p:
return p
h = request.headers.get("authorization", "")
if h.lower().startswith("bearer "):
return identity.api_key_person(h[7:].strip())
return None
def _auth(request, token, capability, unit_id=None): def _auth(request, token, capability, unit_id=None):
"""Authorise, and return a label naming who acted, for created_by. """Authorise, and return a label naming who acted, for created_by.
@@ -63,11 +87,13 @@ def _auth(request, token, capability, unit_id=None):
for every unit, and the break-glass token reaches everything - it exists for every unit, and the break-glass token reaches everything - it exists
for when identity is broken, so it cannot depend on identity's scoping. for when identity is broken, so it cannot depend on identity's scoping.
""" """
person = auth.current_person(request) person = _person(request)
if person: if person:
if not identity.can(person, capability, unit_id): if not identity.can(person, capability, unit_id):
raise HTTPException(403, "your account does not have %s" % capability raise HTTPException(403, "your account does not have %s" % capability
+ (" for this unit" if unit_id else "")) + (" for this unit" if unit_id else "")
+ (" (key %s is scoped narrower)" % person["key_prefix"]
if person.get("key_scopes") is not None else ""))
return person["email"] return person["email"]
if not ADMIN_TOKEN: if not ADMIN_TOKEN:
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN") raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
@@ -293,3 +319,147 @@ def put_setting(request: Request, key: str, payload: dict = Body(...),
return identity.set_setting(key, payload.get("value"), actor=actor) return identity.set_setting(key, payload.get("value"), actor=actor)
except identity.IdentityError as e: except identity.IdentityError as e:
raise HTTPException(e.status, e.detail) raise HTTPException(e.status, e.detail)
# ----------------------------------------------------------------------------
# API keys (P3). Own keys only - apikeys:own. A key cannot reach these routes
# (apikeys:own is unscopable), and the break-glass token has no person to
# own a key, so both fall out naturally rather than by special case.
# ----------------------------------------------------------------------------
def _key_owner(request, token):
_auth(request, token, "apikeys:own")
person = _person(request)
if not person:
raise HTTPException(403, "keys belong to a signed-in person; the admin token cannot own one")
return person
@router.get("/keys")
def list_keys(request: Request, x_admin_token: str = Header(None)):
"""Your keys, newest first, with state (active / expired / revoked) and
the scopes you may put on a new one. Key secrets are never returned."""
person = _key_owner(request, x_admin_token)
return {"keys": identity.list_api_keys(person["id"]),
"scopable": identity.scopable_caps(person),
"max_days": identity.KEY_MAX_DAYS}
@router.post("/keys", status_code=201)
def create_key(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Mint a key. Body: label, scopes (list), expires_days (optional, 1-365).
The response carries `key` ONCE; it is not stored and cannot be shown again.
Send it as `Authorization: Bearer gls73_...` to /api/admin routes."""
person = _key_owner(request, x_admin_token)
try:
full, row = identity.mint_api_key(person, payload.get("label"), payload.get("scopes"),
payload.get("expires_days"))
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
row["key"] = full
return row
@router.delete("/keys/{key_id}")
def revoke_key(request: Request, key_id: str, x_admin_token: str = Header(None)):
"""Revoke one of your keys. The row stays. A key that is not yours is
404, never 403 - an id is not confirmed to exist."""
person = _key_owner(request, x_admin_token)
try:
row = identity.revoke_api_key(person, key_id)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not row:
raise HTTPException(404, "no such key")
return row
@router.get("/whoami")
def whoami(request: Request, x_admin_token: str = Header(None)):
"""Who the API thinks you are and what you can do - the first thing to
call with a new key."""
person = _person(request)
if person:
return {"email": person["email"], "global_role": person.get("global_role"),
"capabilities": person["capabilities"],
"via": ("key " + person["key_prefix"]) if person.get("key_scopes") is not None else "session"}
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):
return {"email": None, "via": "admin-token", "capabilities": ["*"]}
raise HTTPException(401, "sign in, send a Bearer key, or a valid X-Admin-Token")
# ----------------------------------------------------------------------------
# /api/docs - generated from this router, never hand-written. Path, methods
# and docstring come from FastAPI's route objects; the capability is read
# out of each handler's own _auth() call, so it cannot drift from the check.
# ----------------------------------------------------------------------------
_CAP_RE = re.compile(r"""_auth\([^)]*?["']([a-z_]+:[a-z_]+)["']""")
def route_capability(endpoint):
try:
src = inspect.getsource(endpoint)
except (OSError, TypeError):
return None
m = _CAP_RE.search(src)
if m:
return m.group(1)
if "_key_owner(" in src:
return "apikeys:own"
return None
def describe_routes():
"""The registry, as data. Also what the smoke test checks."""
out = []
for r in router.routes:
methods = sorted(m for m in getattr(r, "methods", []) or [] if m not in ("HEAD", "OPTIONS"))
if not methods:
continue
params = [p.name for p in inspect.signature(r.endpoint).parameters.values()
if p.name not in ("request", "x_admin_token", "payload")]
out.append({"path": r.path, "methods": methods,
"capability": route_capability(r.endpoint),
"params": params,
"doc": inspect.getdoc(r.endpoint) or ""})
return sorted(out, key=lambda d: (d["path"], d["methods"]))
@docs_router.get("/docs", response_class=HTMLResponse)
def api_docs(request: Request, x_admin_token: str = Header(None)):
"""This page. Leader and above."""
_auth(request, x_admin_token, "api:docs")
person = _person(request)
e = _html.escape
rows = "".join(
"<tr><td><code>%s</code></td><td><code>%s</code></td><td><code>%s</code></td>"
"<td>%s</td><td>%s</td></tr>"
% (e(" ".join(d["methods"])), e(d["path"]), e(d["capability"] or "-"),
e(", ".join(d["params"])) or "-", e(d["doc"]).replace("\n", "<br>"))
for d in describe_routes())
mine = ""
if person:
mine = ("<p>You are <code>%s</code>%s with: <code>%s</code>.</p>"
% (e(person["email"]),
" via key <code>%s</code>" % e(person["key_prefix"]) if person.get("key_scopes") is not None else "",
e(", ".join(person["capabilities"]))))
body = ("<!doctype html><html><head><meta charset=utf-8><meta name=robots content=noindex>"
"<meta name=viewport content=\"width=device-width,initial-scale=1\">"
"<title>greenlanescouts73.org admin API</title><style>"
"body{font:15px/1.5 system-ui,sans-serif;margin:0;padding:24px;color:#1c2430;background:#f4f5f7}"
"table{border-collapse:collapse;background:#fff;width:100%%}th,td{text-align:left;vertical-align:top;"
"padding:8px 10px;border-top:1px solid #eef0f3;font-size:14px}th{font-size:12.5px;color:#5b6472}"
"code{font-size:13px}h1{font-size:20px}.n{max-width:900px}</style></head><body><div class=n>"
"<h1>greenlanescouts73.org admin API</h1>"
"<p>Generated from the route registry on every request; there is no hand-written copy to drift.</p>"
"<p><b>Auth</b>, tried in this order: the site session cookie; "
"<code>Authorization: Bearer gls73_...</code> (an API key, scoped to the capabilities its owner chose - "
"mint one at <a href=\"/leaders/keys\">/leaders/keys</a>); <code>X-Admin-Token</code> (break glass, "
"operators only). A signed-in caller without the capability gets 403; an anonymous one gets 401. "
"Bodies are JSON. Start with <code>GET /api/admin/whoami</code>.</p>%s"
"<table><tr><th>Method</th><th>Path</th><th>Needs</th><th>Query / path params</th><th>Notes</th></tr>%s</table>"
"</div></body></html>" % (mine, rows))
return HTMLResponse(body)
+1
View File
@@ -103,6 +103,7 @@ async def _meeting_words_middleware(request, call_next):
# Creates the schema if absent and backfills the legacy leads.jsonl once. # Creates the schema if absent and backfills the legacy leads.jsonl once.
store.init(backfill_jsonl=LEADS) store.init(backfill_jsonl=LEADS)
app.include_router(admin_api.router) app.include_router(admin_api.router)
app.include_router(admin_api.docs_router)
MB = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'] MB = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
MF = ['January','February','March','April','May','June','July','August','September','October','November','December'] MF = ['January','February','March','April','May','June','July','August','September','October','November','December']
+170
View File
@@ -78,6 +78,7 @@ CAPS = {
"calendar:write", "calendar:write",
"unit:write_own", "unit:write_own",
"apikeys:own", "apikeys:own",
"api:docs",
"email:draft", "email:draft",
}, },
"admin": { "admin": {
@@ -91,6 +92,7 @@ CAPS = {
"units:write", "units:write",
"settings:write", "settings:write",
"apikeys:own", "apikeys:own",
"api:docs",
"people:invite_leader", "people:invite_leader",
"people:invite_admin", "people:invite_admin",
"email:draft", "email:draft",
@@ -191,6 +193,25 @@ CREATE TABLE IF NOT EXISTS settings (
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
updated_by TEXT 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 # 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"): if not person or person.get("disabled_at"):
return False 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()): if person.get("global_role") and capability in CAPS.get(person["global_role"], set()):
return True return True
for m in person.get("memberships", []): for m in person.get("memberships", []):
@@ -909,3 +936,146 @@ def set_setting(key, value, actor=None):
finally: finally:
con.close() con.close()
return [s for s in all_settings() if s["key"] == key][0] 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()
+15
View File
@@ -159,5 +159,20 @@ check("leader cannot touch settings", not I.can(leader, "settings:write"))
check("admin spans units", I.can(admin, "unit:write_own", "u1") and I.can(admin, "unit:write_own", "u2")) check("admin spans units", I.can(admin, "unit:write_own", "u1") and I.can(admin, "unit:write_own", "u2"))
check("admin holds settings:write", I.can(admin, "settings:write")) check("admin holds settings:write", I.can(admin, "settings:write"))
print("\napi docs registry")
import admin_api as A
reg = A.describe_routes()
check("registry is non-trivial", len(reg) >= 18)
missing = [d for d in reg if not d["capability"] and d["path"] != "/api/admin/whoami"]
check("every route's capability is read from its own _auth call (except whoami): %s"
% ", ".join(d["path"] for d in missing), not missing)
check("key routes gate on apikeys:own", all(d["capability"] == "apikeys:own" for d in reg if "/keys" in d["path"]))
check("docs carry the handler docstring", all(d["doc"] for d in reg if "/keys" in d["path"]))
class _R:
headers = {"authorization": ""}; cookies = {}
os.environ["ADMIN_TOKEN"] = "t"; A.ADMIN_TOKEN = "t"
page = A.api_docs(_R(), "t").body.decode()
check("docs page renders every route", page.count("<tr><td><code>") == len(reg))
print("\n%d passed, %d failed" % (PASS, FAIL)) print("\n%d passed, %d failed" % (PASS, FAIL))
sys.exit(1 if FAIL else 0) sys.exit(1 if FAIL else 0)
+47
View File
@@ -168,6 +168,53 @@ con.close()
for k in ("invite.created", "invite.consumed", "login.ok", "login.failed", "login.throttled"): for k in ("invite.created", "invite.consumed", "login.ok", "login.failed", "login.throttled"):
check("auth_events records %s" % k, k in kinds) 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") print("\nmeeting words")
D = dict(MEETING_DAY="Tuesday", MEETING_DAYS="Tuesdays", MEETING_DAY_ABBR="Tue", PACK_TIME="6:00 PM", 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") TROOP_TIME="7:30 PM", PACK_CLOCK="6:00", TROOP_CLOCK="7:30")