Settings → Activity: an audit log of what happened to accounts
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m27s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Successful in 4s

Sign-ins and failed sign-ins, accounts created and sign-ups refused,
password changes, resets and reset links, devices linked and unlinked,
invites made and revoked. Each is kept in `audit_events` with the address
it came from, for `audit_retention_days` (Settings → Security, 90 by
default, 0 keeps them forever), and listed newest first for admins under
Settings → Activity. The retention loop deletes older events.

`audit.record` writes in its own session, so a refusal is kept even when
the request's transaction rolls back. A failure to record is logged and
swallowed, never the reason a sign-in fails. A throttled attempt (429) is
not recorded: a row per refused request would make each request in a
flood cost a database write. Throttle trips stay in the app log.

Also: the storage-limit test puts `storage_quota_gb` back afterwards,
since settings outlive the per-test truncate.

#2939 §5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:19:41 -04:00
co-authored by Claude Opus 5.5
parent 043c87a8dc
commit 39b1ebae96
14 changed files with 509 additions and 21 deletions
+41
View File
@@ -0,0 +1,41 @@
"""audit_events: the audit log
Revision ID: 0039
Revises: 0038
Create Date: 2026-10-08
Credential events were only written to the app log, which is not queryable and is
gone at the container's log rotation (#2939 §5). This keeps them in the database for
`audit_retention_days`, for admins to read in Settings → Activity.
## Downgrade
Drops the table and every event in it.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0039"
down_revision = "0038"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"audit_events",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("email", sa.Text(), nullable=True),
sa.Column("address", sa.Text(), nullable=True),
sa.Column("detail", sa.Text(), nullable=True),
)
op.create_index("ix_audit_events_at", "audit_events", ["at"])
def downgrade() -> None:
op.drop_index("ix_audit_events_at", table_name="audit_events")
op.drop_table("audit_events")
+19 -6
View File
@@ -4,7 +4,7 @@ Inkwell is built to run on a LAN and works fine there with no ceremony. Exposing
it changes the threat model: anyone can now reach the login form, and any account is
one guessed password away from someone's whole note history.
This is what the app does about that on its own, and the four things it cannot do for
This is what the app does about that on its own, and the three things it cannot do for
you.
## Do these five things first
@@ -154,6 +154,24 @@ page shows how much an account uses.
`max_attachment_mb` still caps any single file.
## Activity
**Settings → Activity** lists what has happened to accounts, newest first, with the
address each came from:
- sign-ins and failed sign-ins;
- accounts created and sign-ups refused;
- password changes, resets and reset links;
- devices linked and unlinked;
- invites made and revoked.
Events are kept for `audit_retention_days` (Settings → Security, 90 by default; 0
keeps them forever). Admins only.
A throttled attempt (429) is not listed, so that a flood of them costs no database
writes. Throttle trips are in the app log, with every event above:
`docker compose logs app`.
## What it does not do
Know these before you decide who gets an account.
@@ -165,11 +183,6 @@ Know these before you decide who gets an account.
- **An admin who forgets their own password**, with email off and no other admin,
still needs a hand on the database.
- **No second factor.** A password is the whole of it.
- **No audit TABLE.** Credential events — sign-ins, failures, throttle trips, new
accounts, device tokens issued — are written to the application log and readable
with `docker compose logs app`, which is enough to see whether anyone is knocking.
They are not queryable, not retained beyond the container's log rotation, and not
attributable after the fact.
None of these are hard blockers for an instance whose accounts are you and people you
know. They are the reason not to hand out open registration to strangers.
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { formatShortDateTime } from "../notes/datetime";
// Admin: what has happened to accounts on this instance, newest first (#2939 §5).
// Kept for `audit_retention_days` (Settings → Security).
interface AuditEvent {
id: string;
at: string;
event: string;
email: string | null;
address: string | null;
detail: string | null;
}
// One phrase per event name in `audit.py`. A new event needs a line here.
const WORDS: Record<string, string> = {
sign_in: "Signed in",
sign_in_failed: "Sign-in failed",
registered: "Account created",
registration_refused: "Sign-up refused",
reset_requested: "Reset email asked for",
reset_link_made: "Reset link made",
password_reset: "Password reset",
reset_refused: "Reset link refused",
password_changed: "Password changed",
password_change_refused: "Password change refused",
signed_out_elsewhere: "Signed out elsewhere",
device_linked: "Device linked",
device_unlinked: "Device unlinked",
invite_created: "Invite made",
invite_revoked: "Invite revoked",
};
// Shown in red: something was tried and refused.
const REFUSALS = new Set([
"sign_in_failed",
"registration_refused",
"reset_refused",
"password_change_refused",
]);
const events = ref<AuditEvent[]>([]);
const more = ref(false);
const loading = ref(true);
const loadingMore = ref(false);
const error = ref("");
async function page(before?: string) {
const query = before ? `?before=${encodeURIComponent(before)}` : "";
return api.get<{ events: AuditEvent[]; more: boolean }>(`/api/accounts/activity${query}`);
}
async function load() {
error.value = "";
try {
const res = await page();
events.value = res.events;
more.value = res.more;
} catch (e) {
error.value = errorMessage(e, "Couldn't load activity.");
} finally {
loading.value = false;
}
}
async function loadMore() {
const last = events.value[events.value.length - 1];
if (!last) return;
loadingMore.value = true;
try {
const res = await page(last.at);
events.value.push(...res.events);
more.value = res.more;
} catch (e) {
error.value = errorMessage(e, "Couldn't load older activity.");
} finally {
loadingMore.value = false;
}
}
function where(e: AuditEvent): string {
return [formatShortDateTime(e.at), e.address, e.detail].filter(Boolean).join(" · ");
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="flex flex-col gap-5">
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Activity</h2>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div v-if="loading" class="py-6 text-center text-sm text-neutral-400">Loading…</div>
<p v-else-if="!events.length" class="text-sm text-neutral-400">Nothing yet.</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="e in events"
:key="e.id"
class="rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">
<span :class="REFUSALS.has(e.event) ? 'text-red-600 dark:text-red-400' : ''">
{{ WORDS[e.event] ?? e.event }}
</span>
<span v-if="e.email" class="font-normal text-neutral-500"> · {{ e.email }}</span>
</p>
<p class="truncate text-xs text-neutral-400">{{ where(e) }}</p>
</li>
</ul>
<button
v-if="more"
type="button"
class="self-start rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800"
:disabled="loadingMore"
@click="loadMore"
>
Show older
</button>
</section>
</template>
+2
View File
@@ -8,6 +8,7 @@ import BaseButton from "../components/BaseButton.vue";
import PageHeader from "../components/PageHeader.vue";
import InviteList from "../components/InviteList.vue";
import AccountList from "../components/AccountList.vue";
import ActivityList from "../components/ActivityList.vue";
import GroupList from "../components/GroupList.vue";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
@@ -187,5 +188,6 @@ onMounted(load);
<InviteList v-if="items.length" class="mt-10" />
<AccountList v-if="items.length" class="mt-10" />
<GroupList v-if="items.length" class="mt-10" />
<ActivityList v-if="items.length" class="mt-10" />
</div>
</template>
+18 -5
View File
@@ -1,5 +1,5 @@
"""Admin routes over the instance's accounts: list them, and make a password reset
link for one (#5173).
"""Admin routes over the instance's accounts: list them, make a password reset link
for one (#5173), and read what has happened to them (the audit log, #2939 §5).
What a reset link is, and how it is used, is in `password_resets.py`.
"""
@@ -8,11 +8,12 @@ from __future__ import annotations
import logging
import uuid
from quart import Blueprint, g, jsonify
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from . import audit
from .auth import require_admin
from .common import iso
from .common import iso, parse_dt
from .db import session_scope
from .models.user import User
from .password_resets import issue
@@ -20,7 +21,7 @@ from .proxy import client_address
bp = Blueprint("accounts", __name__, url_prefix="/api/accounts")
# Credential events go to the app log, as in `auth` (there is no audit table yet).
# Credential events go to the app log and the audit log, as in `auth`.
logger = logging.getLogger(__name__)
@@ -56,6 +57,18 @@ async def create_reset_link(account_id: str):
token, expires_at = await issue(db, uid, g.user_id)
await db.commit()
email = user.email
admin = await db.get(User, g.user_id)
logger.info("password reset link made for=%s by=%s from=%s", email, g.user_id, client_address())
await audit.record(audit.RESET_LINK_MADE, user_id=uid, email=email, detail=f"by {admin.email}")
# The token goes back exactly once; the client builds the link from it, as for invites.
return jsonify({"token": token, "expires_at": iso(expires_at)}), 201
@bp.get("/activity")
@require_admin
async def activity():
"""The audit log, newest first, one page at a time: `?before=<at>` pages back."""
before = parse_dt(request.args.get("before"))
async with session_scope() as db:
events = await audit.recent(db, before)
return jsonify({"events": events, "more": len(events) == audit.PAGE})
+1 -1
View File
@@ -32,7 +32,7 @@ from .sync import bp as sync_bp, protocol_advertisement
# Without this, `logger.info` from this package goes nowhere: hypercorn configures its
# own access/error loggers and leaves the root logger at WARNING, so the credential
# events in auth.py would be invisible in `docker compose logs` — which is exactly
# where they are meant to be read until an audit table exists (task 2939).
# where they are read live. (Settings → Activity keeps most of them, `audit.py`.)
#
# `force=False` (the default) so a host that has already configured logging keeps its
# own setup; LOG_LEVEL lets an operator turn it up without a code change.
+107
View File
@@ -0,0 +1,107 @@
"""The audit log: what happened to accounts, kept where an admin can read it (#2939 §5).
Sign-ins, failed sign-ins, password changes and resets, devices linked and
unlinked, invites made and revoked. Each is still written to the app log as it
happens; this keeps a copy in `audit_events` for `audit_retention_days`, which the
app log can't promise past the container's log rotation.
What is NOT recorded here: a throttled attempt (429). That stays in the app log
only. A throttle trip is the answer to a flood, and a row per refused request
would make each one of the flood cost a database write.
`record` opens its own session and commits on its own, so an event outlives the
caller's transaction. A refused registration rolls back, but its refusal is still
recorded. A failure to record is logged and swallowed: the audit log is never the
reason a sign-in fails.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from .common import iso
from .db import session_scope
from .models.audit_event import AuditEvent
from .proxy import client_address
from .settings import get_setting
logger = logging.getLogger(__name__)
# The events, by name. The web Activity list words each one; a new event needs a
# line there too (`frontend/src/components/ActivityList.vue`).
SIGN_IN = "sign_in"
SIGN_IN_FAILED = "sign_in_failed"
REGISTERED = "registered"
REGISTRATION_REFUSED = "registration_refused"
RESET_REQUESTED = "reset_requested"
RESET_LINK_MADE = "reset_link_made"
PASSWORD_RESET = "password_reset"
RESET_REFUSED = "reset_refused"
PASSWORD_CHANGED = "password_changed"
PASSWORD_CHANGE_REFUSED = "password_change_refused"
SIGNED_OUT_ELSEWHERE = "signed_out_elsewhere"
DEVICE_LINKED = "device_linked"
DEVICE_UNLINKED = "device_unlinked"
INVITE_CREATED = "invite_created"
INVITE_REVOKED = "invite_revoked"
# One page of the Activity list. Older events are reached with `before`.
PAGE = 100
async def record(
event: str,
*,
user_id: uuid.UUID | None = None,
email: str | None = None,
detail: str | None = None,
) -> None:
"""Keep one event. Call it from inside a request: the address is read from it."""
try:
async with session_scope() as db:
db.add(
AuditEvent(
event=event,
user_id=user_id,
email=email or None,
address=client_address(),
detail=detail,
)
)
await db.commit()
except Exception:
logger.exception("audit event %s was not recorded", event)
def _serialize(e: AuditEvent) -> dict:
return {
"id": str(e.id),
"at": iso(e.at),
"event": e.event,
"email": e.email,
"address": e.address,
"detail": e.detail,
}
async def recent(db, before: datetime | None = None) -> list[dict]:
"""The newest page of events, or the page older than `before`."""
query = select(AuditEvent).order_by(AuditEvent.at.desc(), AuditEvent.id.desc()).limit(PAGE)
if before is not None:
query = query.where(AuditEvent.at < before)
return [_serialize(e) for e in (await db.scalars(query)).all()]
async def sweep_once(*, now: datetime | None = None) -> int:
"""Delete events older than `audit_retention_days`; 0 keeps them forever."""
async with session_scope() as db:
days = int(await get_setting(db, "audit_retention_days"))
if days <= 0:
return 0
cutoff = (now or datetime.now(timezone.utc)) - timedelta(days=days)
deleted = (await db.execute(delete(AuditEvent).where(AuditEvent.at < cutoff))).rowcount
await db.commit()
return deleted
+36 -4
View File
@@ -9,6 +9,7 @@ from datetime import datetime, timezone
from quart import Blueprint, current_app, g, jsonify, request, session
from sqlalchemy import delete, func, select
from . import audit
from .common import iso
from .db import session_scope
from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem
@@ -30,10 +31,9 @@ from .storage import storage_limit, storage_used
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
# Every credential event goes to the app log — there is no audit TABLE yet (see task
# 2939), and until there is, `docker compose logs` is the only way to know whether
# anyone is knocking. That matters most in exactly the window this was written for: a
# freshly-exposed instance.
# Every credential event goes to the app log, which is the live view of whether
# anyone is knocking: `docker compose logs`. Most also go to the audit log
# (`audit.py`), which keeps them for admins to read in Settings → Activity.
#
# The attempted email is included deliberately. It is the operator's own server, and
# "somebody failed a login" without saying against WHICH account tells you nothing you
@@ -246,6 +246,7 @@ async def register():
is_first = user_count == 0
if is_first and time.monotonic() - current_app.config["STARTED_AT"] > SETUP_WINDOW_S:
logger.warning("registration refused (setup window closed) email=%s from=%s", email, client_address())
await audit.record(audit.REGISTRATION_REFUSED, email=email, detail="setup window closed")
return json_error(SETUP_CLOSED, 403)
# The first account bootstraps the admin and is allowed, inside the setup
# window above, even when registration is otherwise closed. After that, an invite lets one person in
@@ -257,9 +258,11 @@ async def register():
invite_id = await redeem(db, invite, email)
if invite_id is None:
logger.warning("registration refused (bad invite) email=%s from=%s", email, client_address())
await audit.record(audit.REGISTRATION_REFUSED, email=email, detail="invite didn't hold")
return json_error(INVALID_INVITE, 403)
elif not is_first and not await get_setting(db, "allow_registration"):
logger.warning("registration refused (closed) email=%s from=%s", email, client_address())
await audit.record(audit.REGISTRATION_REFUSED, email=email, detail="registration closed")
return json_error("registration is closed", 403)
# Returning without a commit rolls back the redemption above with it, so a
# taken email leaves the invite usable.
@@ -296,6 +299,8 @@ async def register():
"account created email=%s admin=%s invite=%s from=%s",
email, is_first, invite_id, client_address(),
)
detail = "first account, admin" if is_first else ("by invite" if invite_id else None)
await audit.record(audit.REGISTERED, user_id=user.id, email=email, detail=detail)
return jsonify(_serialize_user(user)), 201
@@ -318,6 +323,9 @@ async def _check_credentials(db, email: str, password: str, route: str) -> User
return user
_sign_in_failed(email)
logger.warning("%s failed (%s) email=%s from=%s", route, reason, email, client_address())
await audit.record(
audit.SIGN_IN_FAILED, user_id=user.id if user else None, email=email, detail=f"{route}: {reason}"
)
return None
@@ -341,6 +349,7 @@ async def login():
return _bad_credentials()
_sign_in(user)
logger.info("sign-in ok email=%s from=%s", email, client_address())
await audit.record(audit.SIGN_IN, user_id=user.id, email=email)
return jsonify(_serialize_user(user))
@@ -406,8 +415,10 @@ async def forgot_password():
reset_mail_by_account.record(email)
send_later(mail_reset_link(email))
logger.info("password reset requested for=%s from=%s", email, client_address())
await audit.record(audit.RESET_REQUESTED, email=email, detail="emailed")
else:
logger.warning("password reset email capped for=%s from=%s", email, client_address())
await audit.record(audit.RESET_REQUESTED, email=email, detail="not emailed: over the cap")
return jsonify({"ok": True, "message": FORGOT_SENT})
@@ -436,6 +447,7 @@ async def reset_password():
if user_id is None:
_sign_in_failed("")
logger.warning("password reset refused (bad link) from=%s", client_address())
await audit.record(audit.RESET_REFUSED, detail="link didn't hold")
return json_error(INVALID_RESET, 403)
user = await db.get(User, user_id)
user.password_hash = hash_password(password)
@@ -444,6 +456,7 @@ async def reset_password():
logger.info(
"password reset email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address()
)
await audit.record(audit.PASSWORD_RESET, user_id=user.id, email=user.email, detail=_unlinked(unlinked))
return jsonify(_serialize_user(user))
@@ -465,6 +478,10 @@ async def _sign_out_elsewhere(db, user: User, keep_token: str | None) -> int:
return unlinked
def _unlinked(count: int) -> str:
return f"{count} device{'' if count == 1 else 's'} unlinked"
def _stay_signed_in(user: User) -> None:
"""Keep the browser making this request signed in after `_sign_out_elsewhere`. A
device has no session to keep, and isn't given one."""
@@ -501,11 +518,13 @@ async def change_password():
if not user.password_hash or not verify_password(current, user.password_hash):
_sign_in_failed(user.email)
logger.warning("password change refused (wrong password) email=%s from=%s", user.email, client_address())
await audit.record(audit.PASSWORD_CHANGE_REFUSED, user_id=user.id, email=user.email)
return json_error("your current password isn't right", 403)
user.password_hash = hash_password(password)
unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token())
_stay_signed_in(user)
logger.info("password changed email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address())
await audit.record(audit.PASSWORD_CHANGED, user_id=user.id, email=user.email, detail=_unlinked(unlinked))
return jsonify({"ok": True, "devices_unlinked": unlinked})
@@ -522,12 +541,21 @@ async def sign_out_elsewhere():
unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token())
_stay_signed_in(user)
logger.info("signed out elsewhere email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address())
await audit.record(
audit.SIGNED_OUT_ELSEWHERE, user_id=user.id, email=user.email, detail=_unlinked(unlinked)
)
return jsonify({"ok": True, "devices_unlinked": unlinked})
# --- Device (bearer) tokens for native clients — M8 sync hub ---
async def _email_of(db) -> str | None:
"""The signed-in account's address, for the audit log."""
user = await db.get(User, g.user_id)
return user.email if user else None
def _serialize_device(d: DeviceToken) -> dict:
return {
"id": str(d.id),
@@ -578,6 +606,7 @@ async def device_login():
"device token issued email=%s device=%s from=%s", email, row.name, client_address()
)
await db.commit()
await audit.record(audit.DEVICE_LINKED, user_id=user.id, email=email, detail=row.name)
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
@@ -589,6 +618,7 @@ async def create_device():
async with session_scope() as db:
row, token = await _issue_device_token(db, g.user_id, data.get("name") or "")
await db.commit()
await audit.record(audit.DEVICE_LINKED, user_id=g.user_id, email=await _email_of(db), detail=row.name)
return jsonify({"token": token, "device": _serialize_device(row)}), 201
@@ -638,6 +668,7 @@ async def revoke_own_device():
return not_found()
await db.delete(row)
await db.commit()
await audit.record(audit.DEVICE_UNLINKED, user_id=g.user_id, email=await _email_of(db), detail=row.name)
return jsonify({"ok": True})
@@ -655,4 +686,5 @@ async def revoke_device(device_id: str):
return not_found()
await db.delete(row)
await db.commit()
await audit.record(audit.DEVICE_UNLINKED, user_id=g.user_id, email=await _email_of(db), detail=row.name)
return jsonify({"ok": True})
+10 -1
View File
@@ -12,6 +12,7 @@ from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from sqlalchemy.orm import aliased
from . import audit
from .auth import require_admin
from .db import session_scope
from .invites import MAX_DAYS, lifetime_days, serialize
@@ -22,7 +23,7 @@ from .security import generate_token, hash_token
bp = Blueprint("invites", __name__, url_prefix="/api/invites")
# Credential events go to the app log, as in `auth` (there is no audit table yet).
# Credential events go to the app log and the audit log, as in `auth`.
logger = logging.getLogger(__name__)
@@ -49,10 +50,14 @@ async def create_invite():
db.add(invite)
await db.commit()
await db.refresh(invite)
admin = await db.get(User, g.user_id)
logger.info(
"invite created id=%s for=%s days=%s by=%s from=%s",
invite.id, email or "anyone", days, g.user_id, client_address(),
)
await audit.record(
audit.INVITE_CREATED, user_id=g.user_id, email=admin.email, detail=f"for {email or 'anyone'}, {days} days"
)
# The token goes back exactly once. The client builds the link from it, since only
# the browser knows the address people actually reach this server at.
return jsonify({"invite": serialize(invite, now), "token": token}), 201
@@ -92,4 +97,8 @@ async def revoke_invite(invite_id: str):
invite.revoked_at = now
await db.commit()
logger.info("invite revoked id=%s by=%s from=%s", iid, g.user_id, client_address())
admin = await db.get(User, g.user_id)
await audit.record(
audit.INVITE_REVOKED, user_id=g.user_id, email=admin.email, detail=f"for {invite.email or 'anyone'}"
)
return jsonify(serialize(invite, now))
+1
View File
@@ -4,6 +4,7 @@ Imported for side effects only (model registration on Base.metadata).
"""
from . import ( # noqa: F401
audit_event,
device_token,
group,
invite,
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class AuditEvent(Base):
"""One thing that happened to an account: a sign-in, a failed one, a password
changed, a device linked (#2939 §5). Written by `audit.record`, read by admins in
Settings → Activity, and deleted after `audit_retention_days`.
"""
__tablename__ = "audit_events"
__table_args__ = (Index("ix_audit_events_at", "at"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
# What happened, as a short fixed name (`audit.py` lists them).
event: Mapped[str] = mapped_column(Text(), nullable=False)
# The account it happened to, when there is one. SET NULL so the record outlives
# a deleted account; `email` keeps saying whose it was.
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# The address the event names, as typed. For a failed sign-in against no account,
# it is the only thing that says who was being tried.
email: Mapped[str | None] = mapped_column(Text(), nullable=True)
# The client address under the trusted-proxy rule (`proxy.client_address`).
address: Mapped[str | None] = mapped_column(Text(), nullable=True)
# A few words more: why a sign-in failed, which device, who made an invite.
detail: Mapped[str | None] = mapped_column(Text(), nullable=True)
+10
View File
@@ -27,6 +27,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import delete as sa_delete
from sqlalchemy import select
from . import audit
from .config import Config
from .db import session_scope
from .models.label import NoteLabel
@@ -168,4 +169,13 @@ async def run_sweeper() -> None:
raise
except Exception:
logger.exception("trash retention sweep failed; will retry next pass")
# Its own try: a failed trash sweep must not keep the audit log growing.
try:
dropped = await audit.sweep_once()
if dropped:
logger.info("audit retention: deleted %d old event(s)", dropped)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("audit retention sweep failed; will retry next pass")
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
+11
View File
@@ -187,6 +187,17 @@ REGISTRY: list[SettingDef] = [
minimum=1,
maximum=1440,
),
SettingDef(
"audit_retention_days",
"int",
90,
"Activity kept (days)",
"How long sign-ins, password changes and linked devices stay in Settings → "
"Activity. Set to 0 to keep them forever.",
"Security",
minimum=0,
maximum=3650,
),
SettingDef(
"register_limit_per_address",
"int",
+89 -5
View File
@@ -26,10 +26,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import delete, func, select, text, update
from inkwell import mailer, ratelimit
from inkwell import audit, mailer, ratelimit
from inkwell.app import create_app
from inkwell.config import Config
from inkwell.db import dispose_engine, session_scope
from inkwell.models.audit_event import AuditEvent
from inkwell.models.invite import Invite
from inkwell.models.password_reset import PasswordReset
from inkwell.models.settings import Setting
@@ -51,7 +52,7 @@ pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, group_members, groups, invites, password_resets, users"
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, group_members, groups, invites, password_resets, audit_events, users"
@pytest_asyncio.fixture
@@ -481,6 +482,7 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
"register_window_minutes",
"reset_emails_per_account",
"client_downloads_per_hour",
"audit_retention_days",
}
# The UI renders a number input from these, and it cannot offer a safe range it
# was never told about.
@@ -1274,10 +1276,24 @@ async def test_an_admin_and_a_zero_setting_have_no_storage_limit(app_client, db)
admin_use = await (await app_client.get("/api/auth/storage")).get_json()
assert admin_use["limit_bytes"] is None
async with session_scope() as fresh:
await set_settings(fresh, {"storage_quota_gb": 0})
await fresh.commit()
await _set_for_the_test("storage_quota_gb", 0)
try:
assert (await (await guest.get("/api/auth/storage")).get_json())["limit_bytes"] is None
finally:
await _back_to_default("storage_quota_gb")
async def _set_for_the_test(key: str, value) -> None:
"""Settings outlive the per-test truncate: pair this with `_back_to_default`."""
async with session_scope() as fresh:
await set_settings(fresh, {key: value})
await fresh.commit()
async def _back_to_default(key: str) -> None:
async with session_scope() as fresh:
await fresh.execute(delete(Setting).where(Setting.key == key))
await fresh.commit()
# --- Changing a password, and signing out everywhere else (#5105, practice 4) ---
@@ -1375,6 +1391,74 @@ async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_clie
assert resp.status_code == 400
# --- The audit log (#2939 §5) --------------------------------------------------
async def _activity(client) -> list[dict]:
resp = await client.get("/api/accounts/activity")
assert resp.status_code == 200, await resp.get_data(as_text=True)
return (await resp.get_json())["events"]
async def test_the_activity_list_keeps_what_happened_to_accounts(app_client, db):
other, bearer = await _elsewhere(app_client)
wrong = await create_app().test_client().post(
"/api/auth/login", json={"email": "owner@example.test", "password": "not-the-password"}
)
assert wrong.status_code == 401
await app_client.post(
"/api/auth/password", json={"current_password": _PASSWORD, "new_password": "a-brand-new-password"}
)
events = await _activity(app_client)
# Newest first.
assert [e["event"] for e in events] == [
audit.PASSWORD_CHANGED,
audit.SIGN_IN_FAILED,
audit.DEVICE_LINKED,
audit.SIGN_IN,
audit.REGISTERED,
]
by_name = {e["event"]: e for e in events}
assert by_name[audit.SIGN_IN_FAILED]["detail"] == "sign-in: bad password"
assert by_name[audit.DEVICE_LINKED]["detail"] == "Phone"
assert by_name[audit.PASSWORD_CHANGED]["detail"] == "1 device unlinked"
assert by_name[audit.REGISTERED]["detail"] == "first account, admin"
assert all(e["email"] == "owner@example.test" and e["address"] for e in events)
async def test_a_refused_registration_is_kept_though_its_transaction_rolled_back(app_client, db):
await _admin_with_invite(app_client)
refused = await _register("stranger@example.test", invite="not-a-real-invite")
assert refused.status_code == 403
refusals = [e for e in await _activity(app_client) if e["event"] == audit.REGISTRATION_REFUSED]
assert [(e["email"], e["detail"]) for e in refusals] == [("stranger@example.test", "invite didn't hold")]
async def test_only_an_admin_reads_the_activity_list(app_client, db):
guest, _ = await _admin_and_guest(app_client)
assert (await guest.get("/api/accounts/activity")).status_code == 403
async def test_activity_older_than_its_retention_is_swept(app_client, db):
await app_client.post("/api/auth/register", json={"email": "owner@example.test", "password": _PASSWORD})
now = datetime.now(timezone.utc)
async with session_scope() as fresh:
fresh.add(AuditEvent(event=audit.SIGN_IN, email="old@example.test", at=now - timedelta(days=91)))
await fresh.commit()
await _set_for_the_test("audit_retention_days", 0)
try:
assert await audit.sweep_once() == 0, "0 keeps every event"
finally:
await _back_to_default("audit_retention_days")
assert await audit.sweep_once() == 1
emails = {e["email"] for e in await _activity(app_client)}
assert emails == {"owner@example.test"}
# --- Sharing a note (#5174) ---------------------------------------------------
#
# Owner, a recipient, and a stranger who is on the instance but not shared with.