Skip to content

Add authentication audit logging and metrics - #1465

Merged
Tim020 merged 9 commits into
feature/obs-monitoring-docsfrom
feature/obs-auth-audit
Oct 22, 2026
Merged

Tim020 merged 9 commits into
feature/obs-monitoring-docsfrom
feature/obs-auth-audit

Conversation

@Tim020

@Tim020 Tim020 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Records login, logout, password change/reset, token refresh, API token generate/revoke (v1 and v2) and user create/update/delete (v1 and v2) on DigiScript.Audit (INFO success, WARNING failure) with username, user_id and remote_ip, and counts them in digiscript_auth_events_total{event,result} (fixed event set, bounded labels). Failed logins log unknown_user vs bad_password reasons; HTTP responses are unchanged. User management events log the acting admin plus target_user_id. Usernames are sanitised (control chars stripped, 64 cap, JSON-quoted); secrets are never logged. Docs updated in user_config.md.

Tests: full pytest 1176 passed; full Playwright 225 passed (chromium); audit lines inspected in the server log.

🤖 Generated with Claude Code

@Tim020 Tim020 added the claude Issues created by Claude label Oct 22, 2026
Tim020 and others added 2 commits October 22, 2026 18:46
Log login, logout, password change/reset, token refresh and API token
generate/revoke on DigiScript.Audit with sanitised usernames, never
including secrets, and count them in a bounded Prometheus counter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Adds user_create, user_update and user_delete audit events and covers
the v2 users and token controllers plus v1 user create/delete.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Tim020
Tim020 force-pushed the feature/obs-auth-audit branch from 0872527 to 68ff3fb Compare October 22, 2026 17:46
@Tim020
Tim020 changed the base branch from dev to feature/obs-monitoring-docs October 22, 2026 17:47
@Tim020
Tim020 added this pull request to stack #1468 October 22, 2026 17:48
@Tim020
Tim020 marked this pull request as ready for review October 22, 2026 17:52

@Tim020 Tim020 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review round 1

Head SHA: 68ff3fb6a87b0ad318404a60ee3fa3600775c98d

Verdict: Changes needed, but non-blocking — the audit-logging/metrics design itself is solid (fixed-set metric labels, sanitised + JSON-quoted usernames, reST docstrings, strong existing test discipline around secret-leakage). The issues below are accuracy/polish and test-coverage gaps, not correctness bugs that break the feature.

Previous round follow-up: N/A — this is round 1.

SonarQube: Quality gate PASSED. All 5 configured conditions pass (new reliability/security/maintainability ratings, 2.5% duplication vs 3% threshold, 100% security hotspots reviewed — no coverage condition is configured in this gate, so the check-run's "0.0% Coverage on New Code" is informational only and doesn't gate the PR). 4 open MINOR/MAJOR code-smell issues were flagged by Sonar's new-code window (python:S7504 ×2 in server/utils/web/app_metrics.py, python:S5906 and python:S8997 in server/test/digi_server/test_logger.py) — none of these files are touched by this PR's diff (base is the stacked branch feature/obs-monitoring-docs), so they're pre-existing debt from an earlier PR in the stack, surfaced here only by Sonar's new-code period. Not actionable in this PR.

New findings: 6 Important, 9 Suggestion. 9 posted inline; 6 couldn't be anchored to a changed line and are listed below.

Not anchored to a diff line:

  • Important — Every audit() call on a success path sits unguarded between the DB commit and self.finish() (e.g. server/controllers/api/v1/auth/user.py:104,244,334,359,477, token.py:28,68, and the v2 equivalents). If audit() ever raised, the handler would 500 even though the real mutation (login, user create/delete, password change, token issue) already succeeded — and for user_create/user_delete it would also skip the GET_USERS broadcast. Not reachable today (every call site passes a hardcoded literal from EVENTS/RESULTS, and audit()'s internals can't currently throw), but it's an unguarded single point of failure that will get copied to future call sites. Consider a narrow try/except inside audit() itself that logs+swallows rather than propagates.
  • Suggestion — AdminPasswordResetController doesn't audit two of its refusal branches: "User not found" (server/controllers/api/v1/auth/user.py:523, 404) and "Cannot reset your own password" (:529, 400) — both pre-existing, unchanged by this PR, hence not inline-commentable here. user_delete's only_admin refusal is audited; for parity, consider auditing these too with reason=not_found / reason=self_reset.
  • Important — No test asserts that auth_events_total's label set stays restricted to {event, result} (excludes username or other per-request values) — the cardinality-safety claim in auth_audit.py's docstring is never directly checked, e.g. via auth_events_total._labelnames or two different usernames collapsing to the same series.
  • Suggestion — The only_admin failure branches for user_delete (v1 user.py:145) and user_update/user_delete (v2 users.py:140,198) have no audit-assertion test (plausibly hard to reach given the admin-count guard, but untested either way).
  • Suggestion — The "first user, no actor" case (test_auth.py and test_users.py bootstrap-create tests) only asserts the message contains "AUDIT user_create success"; it never asserts username/user_id are absent. A regression that attributed the bootstrap event to a stale actor would pass.
  • Suggestion — password_reset failure via reason=invalid_password (triggered by change_password raising ValueError, user.py:546) has no audit-assertion test; existing reset tests cover not-found/self-reset/missing-id but not this branch.

Strengths: Thorough existing test coverage pairs HTTP-response assertions with assertLogs + Prometheus counter-delta checks at nearly all ~28 call sites, not just status codes; _assert_no_secrets / _no_secrets helpers give a solid regression guard against accidentally logging credentials; the sanitisation design (control-char stripping + truncation + JSON-quoting for usernames, fixed label sets for metrics) is the right shape for this kind of feature; reST docstrings are properly formatted throughout; test_auth_audit.py correctly stays on plain unittest.TestCase since it touches no DB/settings/temp paths.

🤖 Generated with Claude Code

Comment thread docs/pages/user_config.md Outdated
Comment thread server/utils/web/auth_audit.py Outdated
Comment thread server/controllers/api/v1/auth/user.py Outdated
Comment thread server/test/controllers/api/v1/auth/test_auth.py
Comment thread server/utils/web/auth_audit.py
Comment thread server/controllers/api/v2/users/users.py Outdated
Comment thread server/controllers/api/v1/auth/token.py Outdated
Comment thread docs/pages/user_config.md
Comment thread server/utils/web/auth_audit.py Outdated
audit() now logs and swallows failures instead of raising, every
(event, result) series is pre-initialised, ip is quoted unless a valid
address, reset-password refusals are audited, v1 token controllers use
actor(), and docs/tests are tightened.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Tim020

Tim020 commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Review round 1 response (fixes in df45bda). pytest 1216 passed, ruff check and format --check clean.

Decision for Tim to confirm: on an unknown_user login failure the attempted username is still logged (sanitised, capped at 64, JSON-quoted), like sshd, because that is the audit's value. A password mistyped into the username field will therefore appear in the log. The docs no longer claim secrets are never logged; they now say "never deliberately logged", that the username is recorded as typed, and that logs should be treated as sensitive.

Unanchored findings

  • audit() could fail a request after the mutation succeeded: fixed. audit() now never raises. Internal failures are logged with exc_info and swallowed. Unknown event/result is logged at ERROR and not counted, so labels stay bounded. Tests added for both.
  • Label-set cardinality not tested: fixed. Tests assert _labelnames == (event, result) and that different usernames create no new series.
  • reset-password refusal branches unaudited: fixed. reason=not_found and reason=self_reset, with tests.
  • only_admin branches for user_delete (v1) and user_update/user_delete (v2) untested: rejected. They are unreachable through the API. require_admin means the caller is an admin, so a different admin target implies at least two admins. They stay as defensive guards that are audited.
  • Bootstrap create only checked the message: fixed. The v1 and v2 tests now assert the line has no username=/ user_id= and the record has no such attributes.
  • password_reset reason=invalid_password untested: fixed, by patching change_password to raise ValueError.

Inline findings

  • Docs example for bad_password lacked user_id: fixed. Added an unknown_user example without user_id.
  • Stale module docstring: fixed. It now points at EVENTS.
  • Raw username claim contradicting the docs: see the decision above.
  • Weak log-injection test: fixed. It now asserts the exact rendered message, that the lookalike ip= stays inside the quoted username, and that the real ip= appears once.
  • Pre-initialise label pairs: fixed, with a test.
  • bool() in the user_update changed field: fixed. It now logs data[field]!r.
  • v1 token.py hand-spelled actor fields: fixed. It uses actor(self).
  • Stray blank line in the docs list: fixed.
  • ip not quoted: fixed. ip is bare only when it parses as an IP address, otherwise it is JSON-quoted, with tests. The module docstring claim was narrowed to match.

Tim020 and others added 3 commits October 22, 2026 23:21
…ing conflicts with dev's login hardening

LoginHandler now follows dev's throttled flow (no per-username lock, dummy bcrypt
for unknown users) with the audit calls re-applied to each outcome, including a
new 'reason=throttled' audit line for lockouts. Test imports are the union of both
sides, plus a test for the throttled audit line.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Client V3 Test Results

227 tests  ±0   227 ✅ ±0   1s ⏱️ ±0s
 20 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 9d233de. ± Comparison against base commit 9f55fbd.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Python Test Results

    1 files  ± 0      1 suites  ±0   1m 23s ⏱️ +2s
1 270 tests +29  1 270 ✅ +29  0 💤 ±0  0 ❌ ±0 
1 301 runs  +29  1 301 ✅ +29  0 💤 ±0  0 ❌ ±0 

Results for commit 9d233de. ± Comparison against base commit 9f55fbd.

♻️ This comment has been updated with latest results.

@github-actions github-actions Bot added documentation Improvements or additions to documentation server Pull requests changing back end code xlarge-diff labels Oct 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Client Test Results

102 tests  ±0   102 ✅ ±0   0s ⏱️ ±0s
  9 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 9d233de. ± Comparison against base commit 9f55fbd.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Playwright E2E Results (firefox)

227 tests  ±0   227 ✅ ±0   2m 24s ⏱️ -1s
 14 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 9d233de. ± Comparison against base commit 9f55fbd.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Playwright E2E Results (chromium)

227 tests  ±0   227 ✅ ±0   3m 4s ⏱️ +38s
 14 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 9d233de. ± Comparison against base commit 9f55fbd.

♻️ This comment has been updated with latest results.

@Tim020 Tim020 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review round 2

Head SHA: 55951a1a2c0a0f6c0b0e1486c4cab87d77f5bb91

Verdict: Changes needed — one new, gate-blocking finding (SonarQube duplication) introduced since round 1. Everything from round 1 was fixed, several fixes going beyond what was asked (e.g. the IP-quoting fix parses the address properly instead of just always quoting it; audit() is now fully non-raising with its own test).

Previous round follow-up: All 9 round-1 findings verified fixed in commit df45bda ("Address audit review: non-raising audit(), preinit labels, more tests") — replied inline on each thread, not resolving (left for Tim to close). 5 of the 6 findings that couldn't be anchored to a diff line in round 1 were also fixed in the same commit: the unguarded-audit()-on-success-path architecture issue (now wrapped in try/except, swallows and logs), the admin-password-reset refusal audit gaps (reason=not_found/reason=self_reset both added, with a new test), the missing label-cardinality test (test_label_names_are_bounded + test_usernames_do_not_create_series added), the weak first-user-no-actor assertion (now explicitly asserts username/user_id are absent), and the untested password_reset invalid_password branch (new test added). Still open: the only_admin failure branches for user_delete/user_update remain untested (v1 user.py and v2 users.py) — unchanged since round 1, still a Suggestion-level gap, not reposted.

SonarQube: Quality gate now FAILS (was passing at round 1). Failing condition: new_duplicated_lines_density at 7.8%, required ≤3%. All other conditions (reliability/security/maintainability ratings, hotspots reviewed) remain OK, and there are no new bugs/vulnerabilities/code-smell issues — this is purely a duplication regression. Root cause (confirmed against source at head): the v1/v2 controller pairs for user-create, user-delete's admin-guard, and api-token-revoke were already structurally parallel, and adding near-identical audit() calls to both copies in lockstep (needed for v1/v2 parity, correctly done) pushed the match over the 3% threshold. Posted as 4 inline comments (2 duplicate pairs + their mirrors) with a suggested fix: extract the shared bodies into helpers (e.g. in services/user_service.py for user create/delete, a small shared function for token revoke) that both API versions call.

New findings: 4 Critical (all SonarQube duplication, posted inline), 0 Important, 0 Suggestion. No other new issues — the only genuinely new application code since round 1 (the audit() call for the login-throttle-rejection branch, now auditable as reason=throttled) is correct and well-tested; the login_throttle mechanism itself predates this PR (merged in from a sibling branch) and is out of this PR's own scope.

Strengths: Round 1's response was unusually thorough — every finding fixed, several improved beyond the letter of what was asked, and new tests added for each. The docs are now precise and honest about the one inherent limitation (a mistyped password in the username field gets logged) rather than overclaiming.

🤖 Generated with Claude Code

Comment thread server/controllers/api/v1/auth/user.py Outdated
Comment thread server/controllers/api/v2/users/users.py Outdated
Comment thread server/controllers/api/v1/auth/token.py Outdated
Comment thread server/controllers/api/v2/users/token.py Outdated
Move user create/delete and API token generate/revoke (with their audit
calls) into utils/web/user_admin.py, called by both API versions. Pure
refactor: responses, status codes, audit lines and broadcasts unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Tim020

Tim020 commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 (fix in bd30551)

SonarQube duplication gate: extracted the shared v1/v2 handler bodies (user create, user delete guard and removal, API token generate and revoke, including their audit calls) into server/utils/web/user_admin.py. Both API versions call these helpers. Pure refactor with no test changes: responses, status codes, audit lines and counters are identical and the existing suites are the safety net. Full pytest 1264 passed, ruff check and format --check clean. Login/throttle flow untouched. A difflib check of matching runs of 6+ lines between the v1 and v2 controllers finds 14 matching lines for user.py/users.py (imports and the unrelated GET list handlers) and 8 for token.py (the GET has_token handlers), none in the flagged ranges. Sonar cannot be run locally. The new helpers have no separate tests since the controller tests cover every branch except the existing untested only_admin guards.

@sonarqubecloud

Copy link
Copy Markdown

@Tim020 Tim020 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review round 3

Head SHA: 9d233de74765c4522b223fcb8e30267af3563b10

Verdict: Ready to merge, as far as this review can tell. No new findings.

Previous round follow-up: All 4 round-2 findings verified fixed in commit bd30551 ("Share v1/v2 user and API-token handler bodies to remove duplication") — replied inline on each thread and resolved all 4. Checked the extraction itself, not just the Sonar number: create_user(), delete_user(), generate_api_token() and revoke_api_token() now live once in the new server/utils/web/user_admin.py and are called from both v1 and v2 controllers, which are reduced to thin wrappers. Confirmed it's a genuinely pure move — logic is unchanged, the one real v1/v2 behavioral difference (v1's "Could not find user to delete" 400 vs v2's "User not found" 404) is correctly kept in each caller rather than accidentally unified, the per-endpoint decorators (@redact_data_paths, @require_admin, @no_live_session, etc.) are untouched, and no unused imports were left behind in the four trimmed-down files. No other findings remain open from round 1 or 2.

SonarQube: Quality gate PASSES. new_duplicated_lines_density is back to 0.0% (required ≤3%) and there are 0 new issues of any kind.

New findings: None. This round's only change beyond the round-2 fix is the new server/utils/web/user_admin.py file itself, which I read in full — docstrings are accurate reST, no secrets handling changed, and the existing v1/v2 test suites (unchanged this round) continue to exercise these functions through both API versions since the controllers now delegate to them.

Strengths: Clean, minimal extraction — the shared module's docstring explains why it exists in one sentence, each function's docstring states its precondition (e.g. delete_user documents that the caller must have already loaded user_to_delete), and the refactor commit message explicitly calls out that it's a pure move with no behavioral change, which the diff bears out.

🤖 Generated with Claude Code

@Tim020
Tim020 enabled stacked auto-merge (squash) October 22, 2026 23:10
@Tim020
Tim020 merged commit 42bcdde into dev Oct 22, 2026
28 checks passed
@Tim020
Tim020 deleted the feature/obs-auth-audit branch October 22, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude Issues created by Claude documentation Improvements or additions to documentation server Pull requests changing back end code xlarge-diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant