Skip to content

Add role-based access control and an audit log of mutating actions - #351

Merged
icebergai-review-bot[bot] merged 1 commit into
mainfrom
claude/dependabot-prs-review-raggzc
Sep 2, 2026
Merged

Add role-based access control and an audit log of mutating actions#351
icebergai-review-bot[bot] merged 1 commit into
mainfrom
claude/dependabot-prs-review-raggzc

Conversation

@richardmhope

Copy link
Copy Markdown
Collaborator

Summary

Two Phase-2 rollout gates from PLAN.md, delivered together because the second depends on the first: a SOC needs to know who can change things before a trail of what changed means anything.

Closes #33
Closes #34

RBAC (#33)

User.is_admin is replaced by User.roleadmin / analyst / auditor, backed by the ck_user_role CHECK (the #217 "enforce it in the DB" rule; tests/test_rbac.py fails if the enum and the constraint drift). No compatibility property, no API field — the issue said replace.

Role Can Cannot
admin everything
analyst add / bulk / inventory / delete / refresh / watchlist / triage extensions; own keys + password users, alert destinations + rules, proxy/SSO settings, threat-list ingest, read the audit log
auditor read everything they own; read the audit log; own keys + password every other mutation → 403

Design decisions worth reading before the diff (each was confirmed with the author):

  • require_role(*roles) is an explicit allowed set, not a rank. The audit log is readable by admin and auditor but not analyst — no linear hierarchy expresses that, and a rank check invites "at least X" mistakes. Surfaced as deps.AdminUser / AnalystUser / AuditReader plus …UI 303 variants. A 403 names the required roles, never the caller's own.
  • Rules go with destinations as one admin-only "alerting" capability. A rule must reference a destination the same user owns, so an analyst who can't create destinations could never build a usable rule anyway.
  • Auditors keep self-service over their own credentials. There is no admin password-reset endpoint; strictly read-only would mean a compromised auditor password could never be rotated. An auditor's API key carries the auditor role, so it is not an escalation path (tested).
  • Unmapped SSO users stay analyst. The sync runs on every login and a demotion revokes sessions, so switching the default to auditor would lock every existing unmapped SSO user out at their next sign-in. Least-privilege is opt-in: group=auditor. map_role is any-match, highest wins; the old group=user value is kept as an alias of analyst so existing configs keep working.
  • The API is the enforcement point. Templates get can_write / can_manage_alerts / can_read_audit from _render and hide controls the API would 403; the add/bulk pages redirect an auditor and the audit page redirects an analyst. Hiding is UX, not security.

Migration backfills is_admin=true → admin, everything else → analyst. The one behaviour change for existing non-admins is that destination/rule management moves behind the admin role (called out in the CHANGELOG under Changed).

Audit log (#34)

AuditLog(actor_id, actor, action, target_type, target_id, detail, ip, at) written on every mutating action. The invariant the whole thing rests on:

The row commits with the change. audit.record() only session.adds; the route's own commit() lands both. A rejected request writes nothing; a committed change cannot lack its row.

That shaped several details:

  • Helpers that own the commit take the row explicitlyoidc_settings/proxy_settings.update_settings(..., audit=audit.build(...)) adds it right before their commit, and only when a change is actually written (a no-op save or a validation failure leaves no entry — tested).
  • _enroll_extension flushes, then records so extension.create carries the new id, then commits both. A placeholder discarded after a failed first fetch records extension.discard, so the trail never describes a row that no longer exists (tested).
  • Sends with external effect commit the row first. A destination test can create a real Jira ticket; the attempt is recorded before the wire is touched.
  • Detail never carries secrets. Settings changes record field names (proxy_url carries credentials, Outbound proxy support for all egress (store fetching + webhook delivery) #216); keys record prefix/suffix only; password changes record nothing but the event. test_audit_detail_never_carries_a_secret_shaped_key greps every literal detail key in app/routes/ for credential-shaped names.
  • A new mutating endpoint cannot ship un-audited. test_every_mutating_route_is_audited_or_allowlisted AST-walks every non-GET @router handler and fails unless it calls audit.record/audit.build, an auditing helper, or sits on a justified allowlist (login/logout only — authentication events, not workspace mutations).
  • Forensics survive deletion: actor_id is SET NULL, actor snapshots the username (tested: a user acts, is deleted, the rows still name them). The table is deliberately excluded from retention.prune_expired.

SSO provisioning (user.provision) and IdP-driven role changes (user.role_sync) are audited inside oidc/service.py with the account as actor.

Read side: GET /api/audit (admin | auditor; filter by actor/action/target/since; limit ≤ 500 + offset) and a server-rendered /admin/audit — no page JS on purpose, since the auditor is the role most likely to sit behind a locked-down browser.

Test evidence

Full suite against Postgres 16 on CPython 3.14.7, plus every lint job's tool at the version ci.yml pins:

$ uv run pytest -q -p no:warnings --durations=5
1053 passed in 111.46s

$ uvx ruff@0.16.5 check app tests e2e            All checks passed!
$ uvx ruff@0.16.5 format --check app tests e2e alembic   147 files already formatted
$ uv run mypy app                                Success: no issues found in 65 source files
$ uv run bandit -q -c pyproject.toml -r app      (no findings)
$ uvx vulture@2.16 app vulture_whitelist.py      exit=0
$ uv lock --check                                Resolved 89 packages

New tests (tests/test_rbac.py 30 cases, tests/test_audit.py 15, tests/test_migrations.py +1):

  • Auditor — parametrised over 23 mutating routes, each asserted 403; reads 200; own key mint/revoke + password change 200; a non-readonly auditor bearer key still 403s on writes.
  • Analyst — triage/watchlist/delete on own extension 200; parametrised over 15 admin routes (destinations, rules, users, settings, threat list, audit read), each 403.
  • Schemack_user_role literals == Role values; inserting role='superuser' raises IntegrityError; require_role() with no roles raises.
  • UI — rail per role (admin: Administration + Audit link; analyst: Add but no Administration; auditor: Audit group, no Add, auditor · read-only label); add/bulk 303 for auditor, 200 for analyst; audit page 303 for analyst; detail page has no toggleWatchlist/refreshNow/deleteExt/saveTriage for an auditor; account page shows Managed by an admin to an analyst.
  • Audit rows — create (with the new id + via), create→discard on failed first fetch, triage (resulting stripped values + effective score), watchlist, delete (row outlives the extension), destination + rule CRUD ordering, key lifecycle (raw key absent from detail), user lifecycle with actor_id nulled after deletion, settings (field names only, proxy hostname absent, no-op writes nothing), threat-list ingest, OIDC provision + role sync. Rejected requests write nothing: 422 / 404 / 409 / invalid proxy mode.
  • Read side — newest-first, filters, limit=0 → 422, anon 401, auditor 200, analyst 403; page renders entries.
  • Migrationd6e7f8a9b0c1 → head backfills {root: admin, bob: analyst}, DDL default dropped, CHECK live; downgrade restores is_admin with both rows intact; upgrade again clean.

Checklist

  • The four CI gates pass locally — pytest (1053), ruff check + format --check, mypy app, bandit — output above.
  • Tests added — 46 new cases across test_rbac.py, test_audit.py, test_migrations.py; existing is_admin usages migrated in 8 test modules.
  • CHANGELOG.md — two Added entries + a Changed section for the POST /api/users field change and destinations/rules becoming admin-only.
  • CLAUDE.md — RBAC + audit-log invariants under Security (incl. the ORM-expiry trap a trail commit introduces); .claude/rules/architecture.md updated for models, audit.py, auth.py, deps.py, oidc/service.py, users.py, retention.py.
  • app/templates/help.html — new Roles table and Audit log section; SSO role-map bullet rewritten. Also DEPLOYMENT.md, .env.example, the Helm values comment, and the website's alerts.md / security.md.
  • uv lock — n/a, no dependency change (uv lock --check clean).
  • No secrets, credentials, or internal hostnames in the diff or the logs above.

Follow-ups (deliberately out of scope)

  • Login / logout / failed-login events in the trail — authentication events, not mutations; the allowlist in test_audit.py names them.
  • Bounded audit retention — the table is never pruned today; a separate ICEBERG_EBS_AUDIT_RETENTION_DAYS is the natural shape if a deployment needs it.
  • Changing an existing user's role — there is no PATCH /api/users/{id}; today it is delete + recreate (or the IdP for SSO accounts).

🤖 Generated with Claude Code

https://claude.ai/code/session_01XY2M1RbUVgATMDceLubuBz


Generated by Claude Code

Closes #33: User.is_admin becomes a role of admin / analyst / auditor,
backed by the ck_user_role CHECK. require_admin is generalised to
require_role(*roles) / require_role_ui(*roles) with explicit allowed sets,
surfaced through deps.AdminUser (users, destinations + rules, settings,
threat lists), deps.AnalystUser (every extension mutation) and
deps.AuditReader (the trail — admin or auditor). Reads and self-service
credential routes stay open to every role so an auditor can rotate a
leaked password or key. The UI hides controls the API would 403, and the
add/bulk/audit pages redirect the wrong role.

SSO role maps accept group=admin|analyst|auditor ("user" stays an alias
of analyst). map_role is any-match, highest wins, and an unmapped login
is an analyst: the sync runs on every login and a demotion revokes
sessions, so a changed default would lock an org out at next sign-in.

Closes #34: every mutating route stages an AuditLog row via
audit.record() before its own commit, so the row and the change land in
one transaction — a rejected request writes nothing and a committed
change cannot lack its row. Helpers that own the commit take the row
explicitly (update_settings(..., audit=)); enrollment records
extension.create after flushing so it carries the id, and a placeholder
discarded after a failed first fetch records extension.discard. Sends
with external effect commit the row before touching the wire. Detail
never carries secrets (field names for settings, prefix/suffix for keys,
nothing for passwords) and a static test greps for credential-shaped
keys. A second static test walks every non-GET route and fails unless it
audits or sits on the justified allowlist, so a new endpoint cannot ship
un-audited. Actor rows are SET NULL on user deletion with the username
snapshotted; the table is excluded from retention pruning. Read side:
GET /api/audit and a server-rendered /admin/audit for admins + auditors.

Migration backfills is_admin -> admin, everything else -> analyst, and
the downgrade restores is_admin without deleting a row; verified
up -> down -> up on a scratch database.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY2M1RbUVgATMDceLubuBz

@icebergai-review-bot icebergai-review-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict

APPROVE

Completed bounded review across 3 immutable scope(s). Two findings: audit records expose webhook capability URLs, and OIDC provisioning does not atomically commit its audit record with the created user. No actionable regressions found in the supplied immutable change. No actionable findings in the supplied change.

Scope health

Convergence: slowing. Review mode: initial.
Recommended action: CONTINUE_INCREMENTAL.

  • 20 changed files

Prior findings

Finding Status
No prior finding state

New findings

Root cause: Sensitive destination targets are treated as audit metadata rather than redacted identifiers.

  • FYI · medium: Audit trail exposes webhook capability URLs to auditorsapp/routes/alerts.py:160
    Status: NEW. Attribution: new_in_scope.
    Destination create, update, and delete records persist the raw destination target in audit detail. Webhook targets commonly embed the authorization token in the URL path. The new audit API grants every auditor access to this detail and retains it indefinitely, including after a destination is deleted.
    Invariant: Audit detail must never carry secrets or capability credentials.
    Ownership: Audit-log read access is intentionally available to auditors, while destination credentials must remain restricted.. Behaviour: Creating, updating, or deleting an HTTP alert destination leaks its target through the audit API..
    Evidence: create_destination, update_destination, and delete_destination pass target: body.target or target: dest.target to audit.record; the adjacent comment explicitly calls a webhook URL a "capability token". GET /api/audit returns detail to AuditReader (admin or auditor).
    Independent assessment: Downgraded to advisory: The changed code does persist raw targets in audit records, but the supplied immutable diff does not independently establish that auditors can read those details. The claimed access path is therefore unverified.

Root cause: Retry handling was implemented by separating the user insert commit from audit persistence.

  • FOLLOW-UP ISSUE · medium: OIDC-provisioned users can be committed without their audit entryapp/oidc/service.py:501
    Status: NEW. Attribution: new_in_scope.
    The provisioning path commits the new user, then separately stages and commits user.provision. A crash or transient database failure between those commits leaves a durable user with no audit record; retrying login follows the existing-user path and does not create the missed provision event.
    Invariant: A committed mutating action must commit with its audit row.
    Ownership: OIDC provisioning owns both user creation and its audit record.. Behaviour: JIT SSO provisioning can create accounts absent from /api/audit..
    Evidence: The code explicitly says the audit row is recorded "after the insert is durable" and performs a second await session.commit() after audit.record, so the user insert and audit row are separate transactions.

Fix-induced regressions

  • None evidenced.

Uncertainty

  • No material uncertainty recorded.

Validation

  • Exact-head CI was supplied as passed; review was limited to the supplied immutable change.
  • Exact-head CI was reported passed in the supplied review data.
  • Reviewed the supplied role-gating and audit-trail changes for reachable authorization, transaction, and secret-exposure regressions.
  • Exact-head CI passed (per review context).
  • Reviewed the supplied test and documentation deltas only; no generated artifacts were excluded.

Residual risks

  • None identified.

@icebergai-review-bot
icebergai-review-bot Bot merged commit 4f4fb43 into main Sep 2, 2026
11 checks passed
@icebergai-review-bot
icebergai-review-bot Bot deleted the claude/dependabot-prs-review-raggzc branch September 2, 2026 02:59
icebergai-review-bot Bot pushed a commit that referenced this pull request Sep 2, 2026
…rovisioning row (#353)

* Harden the audit trail: origin-only destination targets, atomic SSO provisioning row

Two findings from the review of #351, both against invariants that PR set.

Destination create/update/delete recorded the raw target. A Slack or Teams
incoming-webhook URL is a capability token — the path is the credential —
and the trail is readable by auditors and retained forever, so it now
records the target's origin (scheme + host) via alerts._audit_target and
never the path or query. Non-URL targets (email recipients) are kept.

JIT SSO provisioning committed the new user, then staged and committed its
user.provision row separately; a crash between the two left a durable
account with no trail entry, and the returning-user path never records it.
The row is now staged right after the insert is flushed (so it carries the
id) and lands in the same commit, on both the first attempt and the
username-collision retry — the unique-violation rollback discards both, so
exactly one row exists naming the final username.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY2M1RbUVgATMDceLubuBz

* Strip URL userinfo from audited destination targets

Review finding on #353: urlsplit().netloc keeps a user:pass@ prefix, so
_audit_target could still record a basic-auth credential embedded in a
destination URL. The origin is now built from hostname and optional port
(IPv6 brackets restored, a non-numeric port dropped rather than echoed),
never from netloc. Covered by unit cases for userinfo, IPv6 and port
handling plus an end-to-end route test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY2M1RbUVgATMDceLubuBz

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit log of mutating actions RBAC: role enum + require_role

2 participants