auth: an audit trail for everything that changes who can do what

Phase 1 created auth_audit_log and deliberately left spoolman/auth/audit.py
unwritten, because an empty stub fails the docstring lint. This writes it.

What is recorded is every sign-in attempt plus anything that alters
permissions. Ordinary data changes are deliberately excluded: creating a spool
is already visible in the spool, and mixing those in would bury the handful of
security-relevant events under thousands of routine ones, which is how audit
logs stop being read.

Three properties are load-bearing:

Writing never breaks the operation it describes. record() swallows and logs
its own failures, and uses its own database session rather than the caller's.
Login attempts have already committed by the time they are audited, several
call sites hold no session at all, and sharing one would let an audit write
roll back the thing it was recording.

The response and the log disagree on purpose. A failed sign-in tells the
caller only "incorrect username or password" -- distinguishing the cases would
let anyone enumerate accounts -- while the audit entry records which case it
was. Its readers are administrators who already know which accounts exist, and
"somebody is trying to sign in to the account you disabled" is exactly what a
log is for.

Entries outlive their subjects. Deleting a user detaches their entries rather
than cascading, since a log that erases what an account did the moment the
account is removed is not evidence of anything. The username survives in the
target column, so the trail stays readable.

Retention is a database setting rather than an environment variable: it is a
policy an administrator revises, not a deployment parameter, and changing it
should not need a restart. The nightly sweep is only scheduled when auth is
enabled, so existing instances do not grow a job that can never do anything.
It runs half an hour after the backup so the two do not contend for SQLite's
single write lock.

Reading the log needs admin. It holds addresses, user agents and the list of
accounts, so serving it at 'read' would hand an anonymous browser a map of the
instance's users.

Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
This commit is contained in:
Donkie committed 2026-07-28 13:39:24 +02:00
1 parent 0f3a3918c1
commit b4d7955eaa
8 files changed
+618 -2

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
"""Audit log endpoint.
Read-only, and administrators only. The log records addresses, user agents and which
accounts exist, so exposing it at the ``read`` level would hand an anonymous reader a
map of the instance's users and a record of every sign-in attempt against them.
There is deliberately no way to write an entry through the API, and no way to delete
one. Entries appear as a side effect of the operations that produce them, and leave only
when the retention window in :data:`spoolman.auth.audit.RETENTION_SETTING` passes over
them. A log that its subjects can edit is not evidence of anything.
"""
# ruff: noqa: D103
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from spoolman.auth.audit import AuditEvent
from spoolman.auth.dependencies import require_admin
from spoolman.auth.principal import Principal
from spoolman.database import auth_audit as auth_audit_db
from spoolman.database import auth_user as auth_user_db
from spoolman.database.database import get_db_session
from spoolman.exceptions import ItemNotFoundError
from . import models
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/auth/audit",
tags=["auth"],
)
# Cap on a single page. The table is the one in the schema that grows without an upper
# bound, so an unbounded list endpoint over it is an easy way to exhaust memory.
MAX_LIMIT = 500
DEFAULT_LIMIT = 100
@router.get(
"",
name="Read the audit log",
description=(
"List audit entries, newest first. Administrators only. Entries cannot be "
"created or deleted through the API; they appear as a side effect of the "
"operations they record and expire with the retention setting."
),
response_model=list[models.AuthAuditEntry],
)
async def find(
db: Annotated[AsyncSession, Depends(get_db_session)],
_principal: Annotated[Principal, Depends(require_admin())],
limit: Annotated[int, Query(ge=1, le=MAX_LIMIT, description="Maximum number of entries to return.")] = (
DEFAULT_LIMIT
),
offset: Annotated[int, Query(ge=0, description="How many entries to skip.")] = 0,
event: Annotated[str | None, Query(description="Only return entries for this event.")] = None,
actor_user_id: Annotated[int | None, Query(description="Only return entries by this user.")] = None,
) -> Response:
entries, total = await auth_audit_db.find(
db=db,
limit=limit,
offset=offset,
event=event,
actor_user_id=actor_user_id,
)
# Resolve the usernames in one pass rather than per row. A page of a hundred entries
# is typically a handful of distinct actors, and the alternative is a hundred
# queries against a database that may be a single SQLite file.
usernames: dict[int, str] = {}
for actor_id in {entry.actor_user_id for entry in entries if entry.actor_user_id is not None}:
try:
usernames[actor_id] = (await auth_user_db.get_by_id(db, actor_id)).username
except ItemNotFoundError:
# The account was deleted between the log write and now. The entry stays,
# unattributed, which is the point of keeping history past its subject.
logger.debug("Audit entry references user %d, which no longer exists.", actor_id)
return JSONResponse(
content=jsonable_encoder(
[
models.AuthAuditEntry.from_db(
entry,
usernames.get(entry.actor_user_id) if entry.actor_user_id is not None else None,
)
for entry in entries
],
exclude_none=True,
),
headers={"x-total-count": str(total)},
)
@router.get(
"/events",
name="List audit event types",
description=(
"List every event name this server can record, so a client can offer a filter "
"without hardcoding the vocabulary."
),
response_model=list[str],
)
async def events(
_principal: Annotated[Principal, Depends(require_admin())],
) -> Response:
return JSONResponse(content=[str(event) for event in AuditEvent])
+56
View File
@@ -21,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from spoolman import env
from spoolman.auth import cookies
from spoolman.auth.audit import AuditEvent, record
from spoolman.auth.dependencies import anonymous_read_enabled, require_user, resolve_principal
from spoolman.auth.hashing import (
dummy_verify_async,
@@ -34,6 +35,7 @@ from spoolman.auth.principal import Principal, PrincipalKind
from spoolman.auth.ratelimit import client_ip, ip_window
from spoolman.database import auth_session as auth_session_db
from spoolman.database import auth_user as auth_user_db
from spoolman.database import models as db_models
from spoolman.database.database import get_db_session
from . import models
@@ -85,6 +87,15 @@ def _session_info(principal: Principal | None, user: models.AuthUserInfo | None
)
def _failure_reason(user: db_models.AuthUser | None) -> str:
"""Say why a sign-in failed before a password was even checked, for the audit log."""
if user is None:
return "no_such_user"
if not user.is_active:
return "account_disabled"
return "no_password_set"
def _throttled(address: str) -> JSONResponse | None:
"""Check the per-address sign-in throttle, returning a 429 response if it has tripped.
@@ -187,6 +198,8 @@ async def setup(
client_ip(request) or "an unknown address",
)
await record(request, AuditEvent.INSTANCE_CLAIMED, actor_user_id=user.id, target=user.username)
token, csrf = await _start_session(db=db, request=request, user_id=user.id, remember=False)
info = _session_info(
Principal(
@@ -238,12 +251,29 @@ async def login(
await dummy_verify_async()
if address:
ip_window.hit(address)
# The audit entry does distinguish these cases, unlike the response. Its readers
# are administrators who already know which accounts exist, and "somebody is
# trying to sign in to the account you disabled" is exactly what a log is for.
await record(
request,
AuditEvent.LOGIN_FAILURE,
actor_user_id=user.id if user is not None else None,
target=username,
detail={"reason": _failure_reason(user)},
)
return _message(UNAUTHORIZED, INVALID_CREDENTIALS)
if auth_user_db.is_locked(user) and user.locked_until is not None:
# Deliberately not spending a hash here: the answer is already decided, and
# burning CPU on locked accounts is a self-inflicted denial of service.
retry_after = max(int((user.locked_until - datetime.utcnow()).total_seconds()), 1)
await record(
request,
AuditEvent.LOGIN_LOCKED,
actor_user_id=user.id,
target=username,
detail={"retry_after": retry_after},
)
return _message(
TOO_MANY_REQUESTS,
"Too many sign-in attempts. Try again shortly.",
@@ -254,9 +284,17 @@ async def login(
await auth_user_db.record_login_failure(db=db, user=user)
if address:
ip_window.hit(address)
await record(
request,
AuditEvent.LOGIN_FAILURE,
actor_user_id=user.id,
target=username,
detail={"reason": "bad_password", "failed_logins": user.failed_logins},
)
return _message(UNAUTHORIZED, INVALID_CREDENTIALS)
await auth_user_db.record_login_success(db=db, user=user)
await record(request, AuditEvent.LOGIN_SUCCESS, actor_user_id=user.id, target=user.username)
# Upgrade the stored hash if the cost parameters have been raised since it was made.
if needs_rehash(user.password_hash):
@@ -298,7 +336,12 @@ async def logout(
# its cookies cleared.
token = request.cookies.get(cookies.SESSION_COOKIE, "")
if token:
# Resolved before the revoke, while the token still identifies somebody. A
# sign-out that cannot say who signed out is not worth recording.
principal = await resolve_principal(request)
await auth_session_db.revoke_by_token(db, token)
if principal is not None and principal.user_id is not None:
await record(request, AuditEvent.LOGOUT, principal=principal, target=principal.username)
response = JSONResponse(content={"message": "Signed out."})
cookies.clear_session_cookies(response, request)
return response
@@ -342,6 +385,7 @@ async def get_session(
)
async def change_password(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_user())],
body: models.PasswordChangeRequest,
) -> Response:
@@ -350,6 +394,13 @@ async def change_password(
user = await auth_user_db.get_by_id(db, principal.user_id)
if user.password_hash is None or not await verify_password_async(body.current_password, user.password_hash):
await record(
request,
AuditEvent.LOGIN_FAILURE,
principal=principal,
target=user.username,
detail={"reason": "bad_password", "during": "password_change"},
)
return _message(BAD_REQUEST, "The current password is incorrect.")
await auth_user_db.set_password(
@@ -357,4 +408,9 @@ async def change_password(
user=user,
password_hash=await hash_password_async(body.new_password),
)
# Other sessions are deliberately left alone. Someone changing their password
# routinely does not expect their phone to be signed out; an administrator who
# suspects a compromise has "sign out everywhere" for that, and a reset performed
# for them revokes everything.
await record(request, AuditEvent.PASSWORD_CHANGED, principal=principal, target=user.username)
return JSONResponse(content={"message": "Password changed."})
+49
View File
@@ -1,5 +1,6 @@
"""Pydantic data models for typing the FastAPI request/responses."""
import json
from datetime import UTC, datetime
from enum import Enum
from typing import Annotated, Literal
@@ -638,3 +639,51 @@ class PasswordChangeRequest(BaseModel):
max_length=MAX_PASSWORD_LENGTH,
description="The new password.",
)
#
# Authentication, phase 2: the audit log.
#
MAX_AUDIT_EVENT_LENGTH = 64
class AuthAuditEntry(BaseModel):
"""One line of the audit trail."""
id: int = Field(description="Unique internal ID of this entry.")
date: SpoolmanDateTime = Field(description="When it happened, in UTC.")
event: str = Field(description="What happened.", examples=["login.success"])
actor_user_id: int | None = Field(default=None, description="Which account acted, if it still exists.")
actor_username: str | None = Field(default=None, description="That account's username, resolved for display.")
actor_kind: str = Field(description="How the actor was authenticated.", examples=["user"])
target: str | None = Field(default=None, description="What was acted on.")
ip: str | None = Field(default=None, description="Where the request came from.")
user_agent: str | None = Field(default=None, description="What the request identified itself as.")
detail: dict | None = Field(default=None, description="Anything else worth keeping.")
@staticmethod
def from_db(entry: models.AuthAuditLog, actor_username: str | None = None) -> AuthAuditEntry:
"""Create a Pydantic audit entry from a database audit entry."""
detail = None
if entry.detail:
try:
parsed = json.loads(entry.detail)
except ValueError:
# A row written by a future version, or corrupted. Showing the raw text
# beats dropping the entry: the point of an audit log is that entries do
# not silently vanish.
parsed = {"raw": entry.detail}
detail = parsed if isinstance(parsed, dict) else {"value": parsed}
return AuthAuditEntry(
id=entry.id,
date=entry.date,
event=entry.event,
actor_user_id=entry.actor_user_id,
actor_username=actor_username,
actor_kind=entry.actor_kind,
target=entry.target,
ip=entry.ip,
user_agent=entry.user_agent,
detail=detail,
)
+15 -1
View File
@@ -19,7 +19,20 @@ from spoolman.exceptions import AuthenticationRequiredError, ItemNotFoundError,
from spoolman.externaldb import get_external_db_name
from spoolman.ws import websocket_manager
from . import auth, export, externaldb, field, filament, models, other, search, setting, spool, vendor
from . import (
audit,
auth,
export,
externaldb,
field,
filament,
models,
other,
search,
setting,
spool,
vendor,
)
logger = logging.getLogger(__name__)
@@ -136,6 +149,7 @@ async def notify(
# Add routers
app.include_router(auth.router)
app.include_router(audit.router)
app.include_router(filament.router)
app.include_router(spool.router)
app.include_router(vendor.router)
+207
View File
@@ -0,0 +1,207 @@
"""The audit trail.
What gets recorded is anything that changes who can do what, plus every sign-in attempt.
Ordinary data changes -- creating a spool, using filament -- are deliberately not
recorded here. Those are already visible in the data itself, and mixing them in would
bury the handful of security-relevant events under thousands of routine ones, which is
how audit logs stop being read.
Writing is best effort. :func:`record` never raises and never blocks the operation it is
describing: an instance whose audit table is somehow unwritable should still let people
sign in. The failure is logged instead, which is the one place an operator will see it.
Each write uses its own database session rather than the caller's. Login attempts have
already committed by the time they are audited, several call sites have no session in
hand at all, and a shared session would mean an audit write could roll back the
operation it was recording. The cost is one extra short transaction on paths that
already do several.
"""
import datetime
import json
import logging
from enum import StrEnum
from typing import Any, Final
from scheduler.asyncio.scheduler import Scheduler
from starlette.requests import Request
from starlette.websockets import WebSocket
from spoolman import env
from spoolman.auth.principal import Principal, PrincipalKind
from spoolman.auth.ratelimit import client_ip
from spoolman.database import auth_audit as auth_audit_db
from spoolman.settings import parse_setting
logger = logging.getLogger(__name__)
USER_AGENT_HEADER: Final = "user-agent"
RETENTION_SETTING: Final = "auth_audit_retention_days"
# Used when the retention setting cannot be read at all. Matches the setting's own
# default, so a database that is briefly unavailable does not silently switch the
# instance to keeping entries forever.
DEFAULT_RETENTION_DAYS: Final = 90
class AuditEvent(StrEnum):
"""Everything the audit log records.
The values are stable identifiers, stored in the database and matched against by the
listing endpoint's filter. Renaming one orphans the history that used the old name,
so add rather than rename.
"""
LOGIN_SUCCESS = "login.success"
LOGIN_FAILURE = "login.failure"
LOGIN_LOCKED = "login.locked"
LOGOUT = "logout"
INSTANCE_CLAIMED = "instance.claimed"
# Event names, not credentials. Ruff's hardcoded-password heuristic only sees the
# word "password" in the member name.
PASSWORD_CHANGED = "password.changed" # noqa: S105
PASSWORD_RESET = "password.reset" # noqa: S105
USER_CREATED = "user.created"
USER_UPDATED = "user.updated"
USER_DELETED = "user.deleted"
APIKEY_CREATED = "apikey.created"
APIKEY_REVOKED = "apikey.revoked"
APIKEY_DELETED = "apikey.deleted"
SESSIONS_REVOKED = "sessions.revoked"
def _actor_kind(principal: Principal | None) -> str:
"""Describe how the actor was authenticated."""
if principal is None:
return str(PrincipalKind.ANONYMOUS)
return str(principal.kind)
async def record(
conn: Request | WebSocket | None,
event: AuditEvent,
*,
principal: Principal | None = None,
actor_user_id: int | None = None,
target: str | None = None,
detail: dict[str, Any] | None = None,
) -> None:
"""Record an event, if auditing applies.
Does nothing when authentication is disabled. An instance that has never turned auth
on has no accounts, no sessions and no keys, so there is nothing meaningful to
attribute an event to, and writing rows to a table nothing reads would just grow the
database of every existing user.
Args:
conn: The request or websocket the event happened on, for the address and user
agent. May be None for events that did not arise from a request.
event: What happened.
principal: Who did it, if a principal had been resolved.
actor_user_id: Who did it, when the user is known but no principal exists --
a failed sign-in against a real account, for instance.
target: What was acted on.
detail: Anything else worth keeping. Stored as a JSON object.
"""
if not env.is_auth_enabled():
return
try:
# Imported here rather than at module scope: this module is reachable from the
# command line tool, and get_db_session belongs to a module that assumes a
# configured database.
from spoolman.database.database import get_db_session # noqa: PLC0415
if actor_user_id is None and principal is not None:
actor_user_id = principal.user_id
generator = get_db_session()
db = await anext(generator)
try:
await auth_audit_db.record(
db=db,
event=str(event),
actor_kind=_actor_kind(principal),
actor_user_id=actor_user_id,
target=target,
ip=client_ip(conn) if conn is not None else None,
user_agent=conn.headers.get(USER_AGENT_HEADER) if conn is not None else None,
detail=json.dumps(detail) if detail else None,
)
finally:
await generator.aclose()
except Exception:
# Never propagates. See the module docstring.
logger.exception("Failed to write audit log entry for %s.", event)
async def retention_days() -> int:
"""Read how long audit entries are kept.
Returns:
int: The retention window in days. Zero means keep everything.
"""
from spoolman.database import setting as setting_db # noqa: PLC0415
from spoolman.database.database import get_db_session # noqa: PLC0415
from spoolman.exceptions import ItemNotFoundError # noqa: PLC0415
definition = parse_setting(RETENTION_SETTING)
try:
generator = get_db_session()
db = await anext(generator)
try:
try:
row = await setting_db.get(db, definition)
except ItemNotFoundError:
return int(json.loads(definition.default))
return int(json.loads(row.value))
finally:
await generator.aclose()
except Exception:
logger.exception("Failed to read the %s setting, keeping the default.", RETENTION_SETTING)
return DEFAULT_RETENTION_DAYS
async def _prune_task() -> None:
"""Apply the configured retention window to the audit log."""
from spoolman.database.database import get_db_session # noqa: PLC0415
days = await retention_days()
if days <= 0:
return
cutoff = datetime.datetime.utcnow().replace(microsecond=0) - datetime.timedelta(days=days)
generator = get_db_session()
db = await anext(generator)
try:
removed = await auth_audit_db.prune_older_than(db, cutoff)
finally:
await generator.aclose()
if removed:
logger.info("Pruned %d audit log entries older than %d days.", removed, days)
def schedule_tasks(scheduler: Scheduler) -> None:
"""Schedule the audit log retention sweep.
Only scheduled when authentication is enabled, since nothing writes to the table
otherwise and the job would be a nightly no-op on every existing instance.
Args:
scheduler: The scheduler to use.
"""
if not env.is_auth_enabled():
return
logger.info("Scheduling automatic audit log pruning.")
# Half an hour after the nightly backup rather than alongside it, so the two do not
# contend for SQLite's single write lock.
scheduler.daily(datetime.time(hour=0, minute=30, second=0), _prune_task) # type: ignore[arg-type]
+169
View File
@@ -0,0 +1,169 @@
"""Helper functions for interacting with audit log database objects.
As with the other ``auth_`` modules, nothing here emits a websocket event. The audit log
is the most sensitive table in the schema -- it records who signed in, from where, and
what they changed -- and ``websocket_manager.send`` fans out to every subscriber
including anonymous readers. Do not add a notifier here.
"""
import datetime
import logging
from sqlalchemy import Select, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import models
logger = logging.getLogger(__name__)
# Caps on the free-form columns, so a hostile user agent or a long username cannot
# overflow the column and fail the insert -- which, on the login path, would turn an
# audit write into a failed sign-in.
TARGET_MAX = 128
USER_AGENT_MAX = 256
IP_MAX = 64
EVENT_MAX = 64
def _clip(value: str | None, limit: int) -> str | None:
"""Truncate a value to fit its column, treating the empty string as absent."""
if not value:
return None
return value[:limit]
async def record(
*,
db: AsyncSession,
event: str,
actor_kind: str,
actor_user_id: int | None = None,
target: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
detail: str | None = None,
) -> models.AuthAuditLog:
"""Append an entry to the audit log.
Args:
db: The database session.
event: What happened. See :class:`spoolman.auth.audit.AuditEvent`.
actor_kind: How the actor was authenticated, or "anonymous" if not at all.
actor_user_id: Which account acted, if the actor was a known user.
target: What was acted on -- a username, a key name, a session count.
ip: Where the request came from.
user_agent: What the request identified itself as.
detail: A JSON object with anything else worth keeping.
Returns:
models.AuthAuditLog: The stored entry.
"""
entry = models.AuthAuditLog(
date=datetime.datetime.utcnow().replace(microsecond=0),
event=event[:EVENT_MAX],
actor_user_id=actor_user_id,
actor_kind=actor_kind,
target=_clip(target, TARGET_MAX),
ip=_clip(ip, IP_MAX),
user_agent=_clip(user_agent, USER_AGENT_MAX),
detail=detail,
)
db.add(entry)
await db.commit()
return entry
def _filtered(stmt: Select, *, event: str | None, actor_user_id: int | None) -> Select:
"""Apply the optional filters shared by the listing and its count."""
if event:
stmt = stmt.where(models.AuthAuditLog.event == event)
if actor_user_id is not None:
stmt = stmt.where(models.AuthAuditLog.actor_user_id == actor_user_id)
return stmt
async def find(
*,
db: AsyncSession,
limit: int | None = None,
offset: int = 0,
event: str | None = None,
actor_user_id: int | None = None,
) -> tuple[list[models.AuthAuditLog], int]:
"""List audit entries, newest first.
Args:
db: The database session.
limit: The maximum number to return.
offset: How many to skip.
event: Only return entries for this event.
actor_user_id: Only return entries by this user.
Returns:
tuple: The entries, and the total number matching the filters.
"""
stmt = _filtered(select(models.AuthAuditLog), event=event, actor_user_id=actor_user_id)
# Ordered by id as well as date: the timestamp is stored to the second, so several
# entries from one request would otherwise come back in an arbitrary order and
# paginate inconsistently.
stmt = stmt.order_by(models.AuthAuditLog.date.desc(), models.AuthAuditLog.id.desc())
count_stmt = _filtered(
select(func.count()).select_from(models.AuthAuditLog),
event=event,
actor_user_id=actor_user_id,
)
total = (await db.execute(count_stmt)).scalar_one()
if offset:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
rows = list((await db.execute(stmt)).scalars().all())
return rows, total
async def prune_older_than(db: AsyncSession, cutoff: datetime.datetime) -> int:
"""Delete audit entries older than a point in time.
Args:
db: The database session.
cutoff: Entries dated before this are removed.
Returns:
int: How many entries were removed.
"""
result = await db.execute(delete(models.AuthAuditLog).where(models.AuthAuditLog.date < cutoff))
await db.commit()
return result.rowcount or 0
async def clear_actor(db: AsyncSession, user_id: int) -> int:
"""Detach a user's audit entries from their account.
Called when the account is deleted. The entries themselves are kept -- an audit log
that loses its record of what an account did the moment that account is removed is
not an audit log -- but the foreign key has to stop pointing at a row that no longer
exists. The username is already recorded in ``target`` on the entries where it
matters, so the trail stays readable.
Args:
db: The database session.
user_id: Whose entries to detach.
Returns:
int: How many entries were detached.
"""
rows = (
(await db.execute(select(models.AuthAuditLog).where(models.AuthAuditLog.actor_user_id == user_id)))
.scalars()
.all()
)
for row in rows:
row.actor_user_id = None
await db.commit()
return len(rows)
+2 -1
View File
@@ -16,7 +16,7 @@ from scheduler.asyncio.scheduler import Scheduler
from spoolman import env, externaldb
from spoolman.api.v1.router import app as v1_app
from spoolman.auth import secret
from spoolman.auth import audit, secret
from spoolman.client import SinglePageApplication
from spoolman.database import database
from spoolman.prometheus.metrics import registry
@@ -291,6 +291,7 @@ async def startup() -> None:
schedule = Scheduler()
database.schedule_tasks(schedule)
externaldb.schedule_tasks(schedule)
audit.schedule_tasks(schedule)
log_auth_status()
+6
View File
@@ -79,3 +79,9 @@ register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
# to every client that can read the API. Deployment secrets belong in environment
# variables; see spoolman/env.py.
register_setting("auth_anonymous_read", SettingType.BOOLEAN, json.dumps(obj=False))
# How many days of audit log to keep. Zero keeps everything. A setting rather than an
# environment variable because retention is a policy an administrator revises, not a
# deployment parameter, and changing it should not need a restart. Not a secret: it says
# nothing about what is in the log.
register_setting("auth_audit_retention_days", SettingType.NUMBER, json.dumps(90))