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
|
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.
|
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.
|
you.
|
||||||
|
|
||||||
## Do these five things first
|
## Do these five things first
|
||||||
@@ -154,6 +154,24 @@ page shows how much an account uses.
|
|||||||
|
|
||||||
`max_attachment_mb` still caps any single file.
|
`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
|
## What it does not do
|
||||||
|
|
||||||
Know these before you decide who gets an account.
|
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,
|
- **An admin who forgets their own password**, with email off and no other admin,
|
||||||
still needs a hand on the database.
|
still needs a hand on the database.
|
||||||
- **No second factor.** A password is the whole of it.
|
- **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
|
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.
|
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 PageHeader from "../components/PageHeader.vue";
|
||||||
import InviteList from "../components/InviteList.vue";
|
import InviteList from "../components/InviteList.vue";
|
||||||
import AccountList from "../components/AccountList.vue";
|
import AccountList from "../components/AccountList.vue";
|
||||||
|
import ActivityList from "../components/ActivityList.vue";
|
||||||
import GroupList from "../components/GroupList.vue";
|
import GroupList from "../components/GroupList.vue";
|
||||||
import { errorMessage } from "../api/errors";
|
import { errorMessage } from "../api/errors";
|
||||||
import { useUiStore } from "../stores/ui";
|
import { useUiStore } from "../stores/ui";
|
||||||
@@ -187,5 +188,6 @@ onMounted(load);
|
|||||||
<InviteList v-if="items.length" class="mt-10" />
|
<InviteList v-if="items.length" class="mt-10" />
|
||||||
<AccountList v-if="items.length" class="mt-10" />
|
<AccountList v-if="items.length" class="mt-10" />
|
||||||
<GroupList v-if="items.length" class="mt-10" />
|
<GroupList v-if="items.length" class="mt-10" />
|
||||||
|
<ActivityList v-if="items.length" class="mt-10" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Admin routes over the instance's accounts: list them, and make a password reset
|
"""Admin routes over the instance's accounts: list them, make a password reset link
|
||||||
link for one (#5173).
|
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`.
|
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 logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from quart import Blueprint, g, jsonify
|
from quart import Blueprint, g, jsonify, request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from . import audit
|
||||||
from .auth import require_admin
|
from .auth import require_admin
|
||||||
from .common import iso
|
from .common import iso, parse_dt
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
from .models.user import User
|
from .models.user import User
|
||||||
from .password_resets import issue
|
from .password_resets import issue
|
||||||
@@ -20,7 +21,7 @@ from .proxy import client_address
|
|||||||
|
|
||||||
bp = Blueprint("accounts", __name__, url_prefix="/api/accounts")
|
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__)
|
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)
|
token, expires_at = await issue(db, uid, g.user_id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
email = user.email
|
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())
|
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.
|
# 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
|
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
|
# 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
|
# 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
|
# 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
|
# `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.
|
# 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 quart import Blueprint, current_app, g, jsonify, request, session
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
|
from . import audit
|
||||||
from .common import iso
|
from .common import iso
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem
|
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")
|
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||||
|
|
||||||
# Every credential event goes to the app log — there is no audit TABLE yet (see task
|
# Every credential event goes to the app log, which is the live view of whether
|
||||||
# 2939), and until there is, `docker compose logs` is the only way to know whether
|
# anyone is knocking: `docker compose logs`. Most also go to the audit log
|
||||||
# anyone is knocking. That matters most in exactly the window this was written for: a
|
# (`audit.py`), which keeps them for admins to read in Settings → Activity.
|
||||||
# freshly-exposed instance.
|
|
||||||
#
|
#
|
||||||
# The attempted email is included deliberately. It is the operator's own server, and
|
# 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
|
# "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
|
is_first = user_count == 0
|
||||||
if is_first and time.monotonic() - current_app.config["STARTED_AT"] > SETUP_WINDOW_S:
|
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())
|
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)
|
return json_error(SETUP_CLOSED, 403)
|
||||||
# The first account bootstraps the admin and is allowed, inside the setup
|
# 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
|
# 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)
|
invite_id = await redeem(db, invite, email)
|
||||||
if invite_id is None:
|
if invite_id is None:
|
||||||
logger.warning("registration refused (bad invite) email=%s from=%s", email, client_address())
|
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)
|
return json_error(INVALID_INVITE, 403)
|
||||||
elif not is_first and not await get_setting(db, "allow_registration"):
|
elif not is_first and not await get_setting(db, "allow_registration"):
|
||||||
logger.warning("registration refused (closed) email=%s from=%s", email, client_address())
|
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)
|
return json_error("registration is closed", 403)
|
||||||
# Returning without a commit rolls back the redemption above with it, so a
|
# Returning without a commit rolls back the redemption above with it, so a
|
||||||
# taken email leaves the invite usable.
|
# taken email leaves the invite usable.
|
||||||
@@ -296,6 +299,8 @@ async def register():
|
|||||||
"account created email=%s admin=%s invite=%s from=%s",
|
"account created email=%s admin=%s invite=%s from=%s",
|
||||||
email, is_first, invite_id, client_address(),
|
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
|
return jsonify(_serialize_user(user)), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -318,6 +323,9 @@ async def _check_credentials(db, email: str, password: str, route: str) -> User
|
|||||||
return user
|
return user
|
||||||
_sign_in_failed(email)
|
_sign_in_failed(email)
|
||||||
logger.warning("%s failed (%s) email=%s from=%s", route, reason, email, client_address())
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -341,6 +349,7 @@ async def login():
|
|||||||
return _bad_credentials()
|
return _bad_credentials()
|
||||||
_sign_in(user)
|
_sign_in(user)
|
||||||
logger.info("sign-in ok email=%s from=%s", email, client_address())
|
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))
|
return jsonify(_serialize_user(user))
|
||||||
|
|
||||||
|
|
||||||
@@ -406,8 +415,10 @@ async def forgot_password():
|
|||||||
reset_mail_by_account.record(email)
|
reset_mail_by_account.record(email)
|
||||||
send_later(mail_reset_link(email))
|
send_later(mail_reset_link(email))
|
||||||
logger.info("password reset requested for=%s from=%s", email, client_address())
|
logger.info("password reset requested for=%s from=%s", email, client_address())
|
||||||
|
await audit.record(audit.RESET_REQUESTED, email=email, detail="emailed")
|
||||||
else:
|
else:
|
||||||
logger.warning("password reset email capped for=%s from=%s", email, client_address())
|
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})
|
return jsonify({"ok": True, "message": FORGOT_SENT})
|
||||||
|
|
||||||
|
|
||||||
@@ -436,6 +447,7 @@ async def reset_password():
|
|||||||
if user_id is None:
|
if user_id is None:
|
||||||
_sign_in_failed("")
|
_sign_in_failed("")
|
||||||
logger.warning("password reset refused (bad link) from=%s", client_address())
|
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)
|
return json_error(INVALID_RESET, 403)
|
||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
user.password_hash = hash_password(password)
|
user.password_hash = hash_password(password)
|
||||||
@@ -444,6 +456,7 @@ async def reset_password():
|
|||||||
logger.info(
|
logger.info(
|
||||||
"password reset email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address()
|
"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))
|
return jsonify(_serialize_user(user))
|
||||||
|
|
||||||
|
|
||||||
@@ -465,6 +478,10 @@ async def _sign_out_elsewhere(db, user: User, keep_token: str | None) -> int:
|
|||||||
return unlinked
|
return unlinked
|
||||||
|
|
||||||
|
|
||||||
|
def _unlinked(count: int) -> str:
|
||||||
|
return f"{count} device{'' if count == 1 else 's'} unlinked"
|
||||||
|
|
||||||
|
|
||||||
def _stay_signed_in(user: User) -> None:
|
def _stay_signed_in(user: User) -> None:
|
||||||
"""Keep the browser making this request signed in after `_sign_out_elsewhere`. A
|
"""Keep the browser making this request signed in after `_sign_out_elsewhere`. A
|
||||||
device has no session to keep, and isn't given one."""
|
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):
|
if not user.password_hash or not verify_password(current, user.password_hash):
|
||||||
_sign_in_failed(user.email)
|
_sign_in_failed(user.email)
|
||||||
logger.warning("password change refused (wrong password) email=%s from=%s", user.email, client_address())
|
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)
|
return json_error("your current password isn't right", 403)
|
||||||
user.password_hash = hash_password(password)
|
user.password_hash = hash_password(password)
|
||||||
unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token())
|
unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token())
|
||||||
_stay_signed_in(user)
|
_stay_signed_in(user)
|
||||||
logger.info("password changed email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address())
|
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})
|
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())
|
unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token())
|
||||||
_stay_signed_in(user)
|
_stay_signed_in(user)
|
||||||
logger.info("signed out elsewhere email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address())
|
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})
|
return jsonify({"ok": True, "devices_unlinked": unlinked})
|
||||||
|
|
||||||
|
|
||||||
# --- Device (bearer) tokens for native clients — M8 sync hub ---
|
# --- 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:
|
def _serialize_device(d: DeviceToken) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(d.id),
|
"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()
|
"device token issued email=%s device=%s from=%s", email, row.name, client_address()
|
||||||
)
|
)
|
||||||
await db.commit()
|
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
|
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:
|
async with session_scope() as db:
|
||||||
row, token = await _issue_device_token(db, g.user_id, data.get("name") or "")
|
row, token = await _issue_device_token(db, g.user_id, data.get("name") or "")
|
||||||
await db.commit()
|
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
|
return jsonify({"token": token, "device": _serialize_device(row)}), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -638,6 +668,7 @@ async def revoke_own_device():
|
|||||||
return not_found()
|
return not_found()
|
||||||
await db.delete(row)
|
await db.delete(row)
|
||||||
await db.commit()
|
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})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@@ -655,4 +686,5 @@ async def revoke_device(device_id: str):
|
|||||||
return not_found()
|
return not_found()
|
||||||
await db.delete(row)
|
await db.delete(row)
|
||||||
await db.commit()
|
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})
|
return jsonify({"ok": True})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from quart import Blueprint, g, jsonify, request
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
from . import audit
|
||||||
from .auth import require_admin
|
from .auth import require_admin
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
from .invites import MAX_DAYS, lifetime_days, serialize
|
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")
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -49,10 +50,14 @@ async def create_invite():
|
|||||||
db.add(invite)
|
db.add(invite)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(invite)
|
await db.refresh(invite)
|
||||||
|
admin = await db.get(User, g.user_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
"invite created id=%s for=%s days=%s by=%s from=%s",
|
"invite created id=%s for=%s days=%s by=%s from=%s",
|
||||||
invite.id, email or "anyone", days, g.user_id, client_address(),
|
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 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.
|
# the browser knows the address people actually reach this server at.
|
||||||
return jsonify({"invite": serialize(invite, now), "token": token}), 201
|
return jsonify({"invite": serialize(invite, now), "token": token}), 201
|
||||||
@@ -92,4 +97,8 @@ async def revoke_invite(invite_id: str):
|
|||||||
invite.revoked_at = now
|
invite.revoked_at = now
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("invite revoked id=%s by=%s from=%s", iid, g.user_id, client_address())
|
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))
|
return jsonify(serialize(invite, now))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Imported for side effects only (model registration on Base.metadata).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from . import ( # noqa: F401
|
from . import ( # noqa: F401
|
||||||
|
audit_event,
|
||||||
device_token,
|
device_token,
|
||||||
group,
|
group,
|
||||||
invite,
|
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 delete as sa_delete
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from . import audit
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
from .models.label import NoteLabel
|
from .models.label import NoteLabel
|
||||||
@@ -168,4 +169,13 @@ async def run_sweeper() -> None:
|
|||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("trash retention sweep failed; will retry next pass")
|
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)
|
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
|
||||||
|
|||||||
@@ -187,6 +187,17 @@ REGISTRY: list[SettingDef] = [
|
|||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=1440,
|
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(
|
SettingDef(
|
||||||
"register_limit_per_address",
|
"register_limit_per_address",
|
||||||
"int",
|
"int",
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ import pytest
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sqlalchemy import delete, func, select, text, update
|
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.app import create_app
|
||||||
from inkwell.config import Config
|
from inkwell.config import Config
|
||||||
from inkwell.db import dispose_engine, session_scope
|
from inkwell.db import dispose_engine, session_scope
|
||||||
|
from inkwell.models.audit_event import AuditEvent
|
||||||
from inkwell.models.invite import Invite
|
from inkwell.models.invite import Invite
|
||||||
from inkwell.models.password_reset import PasswordReset
|
from inkwell.models.password_reset import PasswordReset
|
||||||
from inkwell.models.settings import Setting
|
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.
|
# 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.
|
# 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
|
@pytest_asyncio.fixture
|
||||||
@@ -481,6 +482,7 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
|
|||||||
"register_window_minutes",
|
"register_window_minutes",
|
||||||
"reset_emails_per_account",
|
"reset_emails_per_account",
|
||||||
"client_downloads_per_hour",
|
"client_downloads_per_hour",
|
||||||
|
"audit_retention_days",
|
||||||
}
|
}
|
||||||
# The UI renders a number input from these, and it cannot offer a safe range it
|
# The UI renders a number input from these, and it cannot offer a safe range it
|
||||||
# was never told about.
|
# 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()
|
admin_use = await (await app_client.get("/api/auth/storage")).get_json()
|
||||||
assert admin_use["limit_bytes"] is None
|
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:
|
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()
|
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) ---
|
# --- 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
|
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) ---------------------------------------------------
|
# --- Sharing a note (#5174) ---------------------------------------------------
|
||||||
#
|
#
|
||||||
# Owner, a recipient, and a stranger who is on the instance but not shared with.
|
# Owner, a recipient, and a stranger who is on the instance but not shared with.
|
||||||
|
|||||||
Reference in New Issue
Block a user