seeded events are the site's; every write lands in the action log

calendar_write.owns() now accepts both site namespaces: the 32 events the
2026-08-29 seed stamped site73-<sha1>@greenlanescouts73.org and the site's
own @site73.greenlanescouts73.org. The rule exists to keep the site off
@band.us and off anything a person adds in a calendar client, not off its
own data. Seeded objects are written back at the seeder's object name so
an edit replaces in place; proven on a throwaway against the real store
(edit, still 32 objects, restored byte-for-byte).

auth_events becomes the one action log. Every P2 write - nearby create,
update, deactivate; unit meeting edit; setting change; announcement post
and take-down - now records who, when, and a one-line before -> after for
the fields that changed, alongside the login, key and calendar entries
already there. GET /api/admin/history (history:read, admin and above, not
scopable on a key) reads it newest first with a kind prefix filter and
before= paging; rowid breaks second-resolution ties.

tests/smoke_admin.py 102 -> 112.
This commit is contained in:
2026-09-04 18:10:03 -04:00
parent e52cc60fcf
commit 7287f0b515
4 changed files with 160 additions and 23 deletions
+30 -2
View File
@@ -93,6 +93,7 @@ CAPS = {
"settings:write",
"apikeys:own",
"api:docs",
"history:read",
"people:invite_leader",
"people:invite_admin",
"email:draft",
@@ -967,8 +968,8 @@ 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"}
KEY_UNSCOPABLE = {"account:self", "apikeys:own", "api:docs", "history:read", "people:manage",
"secrets:rotate", "people:invite_leader", "people:invite_admin"}
def scopable_caps(person):
@@ -1093,3 +1094,30 @@ def api_key_person(bearer):
return p
finally:
con.close()
# ---------------------------------------------------------------------------
# History. auth_events is the one action log: logins and invites since P0,
# keys since P3, calendar since P4, and every P2 write since 2026-09-04.
# One table, one screen. Not per-row history tables, and not the dropped
# generic audit table with workflow columns.
# ---------------------------------------------------------------------------
def list_events(limit=200, kind_prefix=None, before=None):
con = connect()
try:
sql = "SELECT e.*, p.email AS actor_email FROM auth_events e LEFT JOIN people p ON p.id = e.actor_id"
where, vals = [], []
if kind_prefix:
where.append("e.kind LIKE ?"); vals.append(kind_prefix + "%")
if before:
where.append("e.at < ?"); vals.append(before)
if where:
sql += " WHERE " + " AND ".join(where)
# rowid breaks ties: `at` is second-resolution and a screen can write
# several rows in one second.
sql += " ORDER BY e.at DESC, e.rowid DESC LIMIT ?"
vals.append(max(1, min(int(limit), 500)))
return [dict(r) for r in con.execute(sql, vals)]
finally:
con.close()