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
+67 -16
View File
@@ -82,6 +82,26 @@ def client_is_lan(request):
return any(ip in n for n in LAN_NETS)
def _log(request, kind, detail):
"""One line in the action log for a write, attributed to whoever the
session or key resolved to; the break-glass token logs as itself."""
person = _person(request)
identity.log_event(kind, person_id=person["id"] if person else None,
actor_id=person["id"] if person else None,
email=person["email"] if person else "admin-token",
detail=detail, ip=auth._client_ip(request))
def _diff(before, after, fields):
"""'town: "X" -> "Y"; meets: "-" -> "Mondays"' for the fields that changed."""
out = []
for f in fields:
a, b = (before or {}).get(f), (after or {}).get(f)
if a != b:
out.append('%s: %r -> %r' % (f, a if a not in (None, "") else "-", b if b not in (None, "") else "-"))
return "; ".join(out) or "no change"
def _person(request):
"""Who is calling: a session first, then an API key, then nobody.
@@ -214,7 +234,7 @@ def create_announcement(request: Request, payload: dict = Body(...), x_admin_tok
decide what to revoke."""
_actor = _auth(request, x_admin_token, "announcements:write")
try:
return store.create_announcement(
rec = store.create_announcement(
message=payload.get("message"),
ends_at=payload.get("ends_at"),
starts_at=payload.get("starts_at"),
@@ -230,6 +250,8 @@ def create_announcement(request: Request, payload: dict = Body(...), x_admin_tok
)
except store.AnnouncementRejected as e:
raise _reject(e)
_log(request, "announcement.created", "%s %r" % (rec["id"], (rec.get("message") or "")[:60]))
return rec
@router.delete("/announcements/{announcement_id}")
@@ -240,7 +262,9 @@ def revoke_announcement(request: Request, announcement_id: str, x_admin_token: s
raise HTTPException(404, "no such announcement")
if not store.revoke_announcement(announcement_id):
raise HTTPException(409, "already revoked")
return store.get_announcement(announcement_id)
rec = store.get_announcement(announcement_id)
_log(request, "announcement.revoked", "%s %r" % (announcement_id, (rec.get("message") or "")[:60]))
return rec
# ----------------------------------------------------------------------------
@@ -264,9 +288,11 @@ def create_nearby(request: Request, payload: dict = Body(...),
x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "nearby:write")
try:
return store.create_nearby(payload)
rec = store.create_nearby(payload)
except store.NearbyRejected as e:
raise _reject(e)
_log(request, "nearby.created", "%s %s %s (%s)" % (rec["id"], rec.get("unit_type"), rec.get("unit_number"), rec.get("town")))
return rec
@router.patch("/nearby/{nearby_id}")
@@ -275,12 +301,15 @@ def update_nearby(request: Request, nearby_id: str, payload: dict = Body(...),
"""Partial update. Saving bumps verified_at to today unless the payload
carries an explicit date - see store.py for why saving is verifying."""
_auth(request, x_admin_token, "nearby:write")
before = store.get_nearby(nearby_id)
try:
rec = store.update_nearby(nearby_id, payload)
except store.NearbyRejected as e:
raise _reject(e)
if not rec:
raise HTTPException(404, "no such nearby unit")
_log(request, "nearby.updated", "%s %s %s: %s" % (nearby_id, rec.get("unit_type"), rec.get("unit_number"),
_diff(before, rec, [k for k in store.NEARBY_FIELDS if k != "verified_at"])))
return rec
@@ -292,7 +321,9 @@ def deactivate_nearby(request: Request, nearby_id: str, x_admin_token: str = Hea
raise HTTPException(404, "no such nearby unit")
if not store.deactivate_nearby(nearby_id):
raise HTTPException(409, "already inactive")
return store.get_nearby(nearby_id)
rec = store.get_nearby(nearby_id)
_log(request, "nearby.deactivated", "%s %s %s" % (nearby_id, rec.get("unit_type"), rec.get("unit_number")))
return rec
# ----------------------------------------------------------------------------
@@ -324,9 +355,11 @@ def update_unit(request: Request, slug_or_id: str, payload: dict = Body(...),
_auth(request, x_admin_token, "unit:write_own",
unit_id=unit["id"] if unit else None)
try:
return identity.update_unit_meets(slug_or_id, payload)
rec = identity.update_unit_meets(slug_or_id, payload)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
_log(request, "unit.updated", "%s: %s" % (rec.get("slug"), _diff(unit, rec, list(identity.UNIT_MEETS_FIELDS))))
return rec
# ----------------------------------------------------------------------------
@@ -345,10 +378,15 @@ def put_setting(request: Request, key: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
"""Set one value, or clear it back to the code default with value: null."""
actor = _auth(request, x_admin_token, "settings:write")
before = identity.get_setting(key) if key in identity.SETTINGS_KEYS else None
try:
return identity.set_setting(key, payload.get("value"), actor=actor)
rec = identity.set_setting(key, payload.get("value"), actor=actor)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
after = identity.get_setting(key)
_log(request, "setting.updated", "%s: %r -> %r%s" % (key, before, after,
" (cleared to default)" if payload.get("value") is None else ""))
return rec
# ----------------------------------------------------------------------------
@@ -535,15 +573,14 @@ def create_event(request: Request, payload: dict = Body(...), x_admin_token: str
"""Add an event. Body: title, date (YYYY-MM-DD), unit (pack|troop|both),
optional end, time (HH:MM 24h), end_time, location, badge, description.
No time = all-day. A timed one-day event with no end_time lasts 90 min."""
actor = _auth(request, x_admin_token, "calendar:write")
_auth(request, x_admin_token, "calendar:write")
try:
ev = calendar_write.clean(payload)
uid = calendar_write.new_uid()
calendar_write.put_event(uid, ev)
except calendar_write.CalendarRejected as e:
raise HTTPException(e.status, e.detail)
identity.log_event("calendar.created", email=actor if "@" in actor else None,
detail="%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
_log(request, "calendar.created", "%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
_bust_site_cache()
return {"uid": uid, "event": _serial(ev)}
@@ -552,16 +589,15 @@ def create_event(request: Request, payload: dict = Body(...), x_admin_token: str
def replace_event(request: Request, uid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Replace one site-owned event in full (same body as POST). A UID the
site does not own is 403 before anything is sent to the store."""
actor = _auth(request, x_admin_token, "calendar:write")
_auth(request, x_admin_token, "calendar:write")
if not calendar_write.owns(uid):
raise HTTPException(403, "the site only manages events it created")
raise HTTPException(403, "the site only manages its own events")
try:
ev = calendar_write.clean(payload)
calendar_write.put_event(uid, ev)
except calendar_write.CalendarRejected as e:
raise HTTPException(e.status, e.detail)
identity.log_event("calendar.updated", email=actor if "@" in actor else None,
detail="%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
_log(request, "calendar.updated", "%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
_bust_site_cache()
return {"uid": uid, "event": _serial(ev)}
@@ -571,16 +607,16 @@ def delete_event(request: Request, uid: str, x_admin_token: str = Header(None)):
"""Remove one site-owned event from the store. Unlike everything else on
this API this really deletes: the calendar's history is Radicale's git
log, not a revoked_at column."""
actor = _auth(request, x_admin_token, "calendar:write")
_auth(request, x_admin_token, "calendar:write")
if not calendar_write.owns(uid):
raise HTTPException(403, "the site only manages events it created")
raise HTTPException(403, "the site only manages its own events")
try:
status = calendar_write.delete_event(uid)
except calendar_write.CalendarRejected as e:
raise HTTPException(e.status, e.detail)
if status == 404:
raise HTTPException(404, "no such event in the store")
identity.log_event("calendar.deleted", email=actor if "@" in actor else None, detail=uid)
_log(request, "calendar.deleted", uid)
_bust_site_cache()
return {"uid": uid, "deleted": True}
@@ -601,3 +637,18 @@ def _bust_site_cache():
main_app._feed_state["fetched"] = 0.0
except Exception:
pass
# ----------------------------------------------------------------------------
# History - the action log, read side. Admin and above: it carries emails
# and IPs from logins alongside the content writes.
# ----------------------------------------------------------------------------
@router.get("/history")
def get_history(request: Request, limit: int = Query(200, ge=1, le=500), kind: str = None,
before: str = None, x_admin_token: str = Header(None)):
"""Newest first. `kind` is a prefix filter (calendar., nearby., login.).
`before` is an ISO stamp for paging. Every write on this API since
2026-09-04 lands here with who did it and a one-line diff."""
_auth(request, x_admin_token, "history:read")
return {"events": identity.list_events(limit=limit, kind_prefix=kind, before=before)}