From 7287f0b515b0450c1ed3dd92620dfa8b002f9a01 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Fri, 4 Sep 2026 18:10:03 -0400 Subject: [PATCH] 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-@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. --- app/admin_api.py | 83 ++++++++++++++++++++++++++++++++++--------- app/calendar_write.py | 25 +++++++++++-- app/identity.py | 32 +++++++++++++++-- tests/smoke_admin.py | 43 ++++++++++++++++++++-- 4 files changed, 160 insertions(+), 23 deletions(-) diff --git a/app/admin_api.py b/app/admin_api.py index d566128..5af3d77 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -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)} diff --git a/app/calendar_write.py b/app/calendar_write.py index 5306f54..e7ec50c 100644 --- a/app/calendar_write.py +++ b/app/calendar_write.py @@ -25,10 +25,16 @@ import datetime import hashlib import os import urllib.error +import urllib.parse import urllib.request import uuid UID_SUFFIX = "@site73.greenlanescouts73.org" +# The 2026-08-29 seed stamped its 32 events site73-@greenlanescouts73.org +# before this namespace existed. They are the site's own data too; the rule +# exists to keep the site off @band.us and off anything a person adds in a +# calendar client, not off itself. +SEED_PREFIX, SEED_SUFFIX = "site73-", "@greenlanescouts73.org" TZID = "America/New_York" UNITS = ("pack", "troop", "both") DEFAULT_TIMED_MINUTES = 90 @@ -70,7 +76,19 @@ def configured(): def owns(uid): - return bool(uid) and str(uid).endswith(UID_SUFFIX) + u = str(uid or "") + return bool(u) and (u.endswith(UID_SUFFIX) or (u.startswith(SEED_PREFIX) and u.endswith(SEED_SUFFIX))) + + +def object_name(uid): + """The CalDAV object the UID lives in. Seeded events sit at .ics as + the seeder wrote them; the site's own at a hashed site-*.ics. Getting + this wrong would create a second object with the same UID, which Radicale + refuses, so the two forms are tested separately.""" + u = str(uid) + if u.endswith(UID_SUFFIX): + return slug(u) + return urllib.parse.quote(u + ".ics", safe="") def new_uid(): @@ -219,8 +237,9 @@ def _request(method, uid, data=None): if not configured(): raise CalendarRejected(503, "calendar write-back is not configured (RADICALE_URL/USER/PASS)") if not owns(uid): - raise CalendarRejected(403, "the site only manages events it created (UIDs ending %s)" % UID_SUFFIX) - url = RADICALE_URL.rstrip("/") + "/" + slug(uid) + raise CalendarRejected(403, "the site only manages its own events (%s or the seeded %s...%s)" + % (UID_SUFFIX, SEED_PREFIX, SEED_SUFFIX)) + url = RADICALE_URL.rstrip("/") + "/" + object_name(uid) req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", "Basic " + base64.b64encode( ("%s:%s" % (RADICALE_USER, RADICALE_PASS)).encode()).decode()) diff --git a/app/identity.py b/app/identity.py index b2c5ec7..835179f 100644 --- a/app/identity.py +++ b/app/identity.py @@ -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() diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py index 05910e6..c07eb60 100644 --- a/tests/smoke_admin.py +++ b/tests/smoke_admin.py @@ -238,7 +238,7 @@ check("timed multi-day with no end_time ends at noon on the end date, as seed.py "DTEND;TZID=America/New_York:20261018T120000" in multi) longt = C.build_ics(uid, C.clean({"title": "A" * 120, "date": "2026-10-03"}), stamp="20260904T000000Z").decode() check("long lines are folded at 75 octets", all(len(l.encode()) <= 75 for l in longt.split("\r\n"))) -check("ownership by suffix", C.owns("x" + C.UID_SUFFIX) and not C.owns("site73-abc@greenlanescouts73.org") +check("ownership by suffix", C.owns("x" + C.UID_SUFFIX) and not C.owns("abc@greenlanescouts73.org") and not C.owns("x@band.us") and not C.owns(None)) check("new uids are owned and unique", C.owns(C.new_uid()) and C.new_uid() != C.new_uid()) check("slug is stable and marked", C.slug("a" + C.UID_SUFFIX).startswith("site-") and C.slug("a" + C.UID_SUFFIX) == C.slug("a" + C.UID_SUFFIX)) @@ -255,13 +255,52 @@ C.RADICALE_URL = ""; C.RADICALE_USER = ""; C.RADICALE_PASS = "" raises("unconfigured put is 503", 503, C.CalendarRejected, C.put_event, uid, ev) check("and nothing was sent", calls == []) C.RADICALE_URL = "http://radicale.test/scouts/site73/"; C.RADICALE_USER = "scoutsite"; C.RADICALE_PASS = "p" -raises("foreign uid put is 403", 403, C.CalendarRejected, C.put_event, "site73-abc@greenlanescouts73.org", ev) +raises("foreign uid put is 403", 403, C.CalendarRejected, C.put_event, "abc@greenlanescouts73.org", ev) raises("foreign uid delete is 403", 403, C.CalendarRejected, C.delete_event, "x@band.us") check("still nothing sent for foreign uids", calls == []) check("owned put goes to the slug with auth", C.put_event(uid, ev) == 201 and calls[-1][0] == "PUT" and calls[-1][1] == "http://radicale.test/scouts/site73/" + C.slug(uid) and calls[-1][2]) check("owned delete", C.delete_event(uid) == 201 and calls[-1][0] == "DELETE") +print("\nseeded events are the site's too") +check("both namespaces owned, nothing else", C.owns("x" + C.UID_SUFFIX) and C.owns("site73-abc123@greenlanescouts73.org") + and not C.owns("abc@greenlanescouts73.org") and not C.owns("x@band.us") and not C.owns("site73-x@site73.example")) +check("object name per namespace", C.object_name("a" + C.UID_SUFFIX) == C.slug("a" + C.UID_SUFFIX) + and C.object_name("site73-abc@greenlanescouts73.org") == "site73-abc%40greenlanescouts73.org.ics") +calls.clear(); C.put_event("site73-abc@greenlanescouts73.org", ev) +check("seeded put goes to the seeder's object name", calls[-1][1].endswith("/site73-abc%40greenlanescouts73.org.ics")) + +print("\naction log") +class _Sess: + def __init__(self, tok): + self.headers = {"x-forwarded-for": "10.0.0.5"}; self.cookies = {"s73_session": tok}; self.client = None +req = _Sess(I.start_session(pid)) +n0 = len(I.list_events(limit=500)) +A.update_unit(req, "pack73", {"meets_time": "18:15"}, None) +A.update_unit(req, "pack73", {"meets_time": "18:00"}, None) +A.put_setting(req, "nearby_source_name", {"value": "Test source"}, None) +A.put_setting(req, "nearby_source_name", {"value": None}, None) +nb = A.create_nearby(req, {"unit_type": "pack", "unit_number": "ZZ1", "town": "A"}, None) +A.update_nearby(req, nb["id"], {"town": "B"}, None) +A.deactivate_nearby(req, nb["id"], None) +an = A.create_announcement(req, {"message": "log me", "ends_at": "2036-01-02T00:00:00+00:00"}, None) +A.revoke_announcement(req, an["id"], None) +ev_rows = I.list_events(limit=500) +kinds = [e["kind"] for e in ev_rows[:len(ev_rows) - n0]] +check("every P2 write lands in the log", set(kinds) >= {"unit.updated", "setting.updated", "nearby.created", + "nearby.updated", "nearby.deactivated", "announcement.created", "announcement.revoked"}) +check("attributed to the person", all(e["actor_email"] == "lanrule@example.test" for e in ev_rows[:len(ev_rows) - n0])) +unit_ev = [e for e in ev_rows if e["kind"] == "unit.updated"][-1] +check("diff is before -> after", "-> '18:15'" in unit_ev["detail"] and "meets_time:" in unit_ev["detail"]) +nb_ev = [e for e in ev_rows if e["kind"] == "nearby.updated"][0] +check("nearby diff names the field", "town: 'A' -> 'B'" in nb_ev["detail"]) +set_ev = [e for e in ev_rows if e["kind"] == "setting.updated"][0] +check("clearing a setting is said so", "cleared to default" in set_ev["detail"]) +check("kind prefix filter and limit", all(e["kind"].startswith("nearby.") for e in I.list_events(kind_prefix="nearby.")) + and len(I.list_events(limit=2)) == 2) +check("history is admin and above, and not scopable on a key", "history:read" in I.CAPS["admin"] + and "history:read" not in I.CAPS["leader"] and "history:read" in I.KEY_UNSCOPABLE) + print("\napi docs registry") import admin_api as A reg = A.describe_routes()