diff --git a/spoolman/api/v1/audit.py b/spoolman/api/v1/audit.py new file mode 100644 index 00000000..ab3a4b23 --- /dev/null +++ b/spoolman/api/v1/audit.py @@ -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]) diff --git a/spoolman/api/v1/auth.py b/spoolman/api/v1/auth.py index 831f4514..7ba4bc42 100644 --- a/spoolman/api/v1/auth.py +++ b/spoolman/api/v1/auth.py @@ -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."}) diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 0d1cda4b..28d175e6 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -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, + ) diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py index 87bfed47..b53158ef 100644 --- a/spoolman/api/v1/router.py +++ b/spoolman/api/v1/router.py @@ -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) diff --git a/spoolman/auth/audit.py b/spoolman/auth/audit.py new file mode 100644 index 00000000..50e99b95 --- /dev/null +++ b/spoolman/auth/audit.py @@ -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] diff --git a/spoolman/database/auth_audit.py b/spoolman/database/auth_audit.py new file mode 100644 index 00000000..278f57b9 --- /dev/null +++ b/spoolman/database/auth_audit.py @@ -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) diff --git a/spoolman/main.py b/spoolman/main.py index 5accca14..7cccf790 100644 --- a/spoolman/main.py +++ b/spoolman/main.py @@ -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() diff --git a/spoolman/settings.py b/spoolman/settings.py index 20c670e5..dacc328c 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -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))