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:
+172
-2
@@ -38,9 +38,13 @@ one CAPS dictionary - never by a role comparison here.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import html as _html
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
import auth
|
||||
import identity
|
||||
@@ -50,6 +54,26 @@ ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip()
|
||||
|
||||
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):
|
||||
"""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 when identity is broken, so it cannot depend on identity's scoping.
|
||||
"""
|
||||
person = auth.current_person(request)
|
||||
person = _person(request)
|
||||
if person:
|
||||
if not identity.can(person, capability, unit_id):
|
||||
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"]
|
||||
if not 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)
|
||||
except identity.IdentityError as e:
|
||||
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)
|
||||
|
||||
@@ -103,6 +103,7 @@ async def _meeting_words_middleware(request, call_next):
|
||||
# Creates the schema if absent and backfills the legacy leads.jsonl once.
|
||||
store.init(backfill_jsonl=LEADS)
|
||||
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']
|
||||
MF = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||
|
||||
+170
@@ -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()
|
||||
|
||||
@@ -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 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))
|
||||
sys.exit(1 if FAIL else 0)
|
||||
|
||||
@@ -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