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
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -4,6 +4,7 @@ Imported for side effects only (model registration on Base.metadata).
|
||||
"""
|
||||
|
||||
from . import ( # noqa: F401
|
||||
audit_event,
|
||||
device_token,
|
||||
group,
|
||||
invite,
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
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, {"storage_quota_gb": 0})
|
||||
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()
|
||||
assert (await (await guest.get("/api/auth/storage")).get_json())["limit_bytes"] is None
|
||||
|
||||
|
||||
# --- 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.
|
||||
|
||||
Reference in New Issue
Block a user