Add role-based access control and an audit log of mutating actions - #351
Merged
icebergai-review-bot[bot] merged 1 commit intoSep 2, 2026
Merged
Conversation
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
Contributor
There was a problem hiding this comment.
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 auditors —
app/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, anddelete_destinationpasstarget: body.targetortarget: dest.targettoaudit.record; the adjacent comment explicitly calls a webhook URL a "capability token".GET /api/auditreturnsdetailtoAuditReader(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 entry —
app/oidc/service.py:501
Status: NEW. Attribution: new_in_scope.
The provisioning path commits the new user, then separately stages and commitsuser.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 secondawait session.commit()afteraudit.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
Bot
deleted the
claude/dependabot-prs-review-raggzc
branch
September 2, 2026 02:59
7 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_adminis replaced byUser.role∈admin/analyst/auditor, backed by theck_user_roleCHECK (the #217 "enforce it in the DB" rule;tests/test_rbac.pyfails if the enum and the constraint drift). No compatibility property, no API field — the issue said replace.adminanalystauditor403Design 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 asdeps.AdminUser/AnalystUser/AuditReaderplus…UI303 variants. A 403 names the required roles, never the caller's own.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_roleis any-match, highest wins; the oldgroup=uservalue is kept as an alias ofanalystso existing configs keep working.can_write/can_manage_alerts/can_read_auditfrom_renderand 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:That shaped several details:
oidc_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_extensionflushes, then records soextension.createcarries the new id, then commits both. A placeholder discarded after a failed first fetch recordsextension.discard, so the trail never describes a row that no longer exists (tested).proxy_urlcarries 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_keygreps every literal detail key inapp/routes/for credential-shaped names.test_every_mutating_route_is_audited_or_allowlistedAST-walks every non-GET@routerhandler and fails unless it callsaudit.record/audit.build, an auditing helper, or sits on a justified allowlist (login/logout only — authentication events, not workspace mutations).actor_idisSET NULL,actorsnapshots the username (tested: a user acts, is deleted, the rows still name them). The table is deliberately excluded fromretention.prune_expired.SSO provisioning (
user.provision) and IdP-driven role changes (user.role_sync) are audited insideoidc/service.pywith 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.ymlpins:New tests (
tests/test_rbac.py30 cases,tests/test_audit.py15,tests/test_migrations.py+1):403; reads200; own key mint/revoke + password change200; a non-readonly auditor bearer key still403s on writes.200; parametrised over 15 admin routes (destinations, rules, users, settings, threat list, audit read), each403.ck_user_roleliterals ==Rolevalues; insertingrole='superuser'raisesIntegrityError;require_role()with no roles raises.auditor · read-onlylabel); add/bulk 303 for auditor, 200 for analyst; audit page 303 for analyst; detail page has notoggleWatchlist/refreshNow/deleteExt/saveTriagefor an auditor; account page shows Managed by an admin to an analyst.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 withactor_idnulled 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.limit=0→ 422, anon 401, auditor 200, analyst 403; page renders entries.d6e7f8a9b0c1→ head backfills{root: admin, bob: analyst}, DDL default dropped, CHECK live;downgraderestoresis_adminwith both rows intact;upgradeagain clean.Checklist
pytest(1053),ruff check+format --check,mypy app,bandit— output above.test_rbac.py,test_audit.py,test_migrations.py; existingis_adminusages migrated in 8 test modules.CHANGELOG.md— two Added entries + a Changed section for thePOST /api/usersfield 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.mdupdated formodels,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. AlsoDEPLOYMENT.md,.env.example, the Helm values comment, and the website'salerts.md/security.md.uv lock— n/a, no dependency change (uv lock --checkclean).Follow-ups (deliberately out of scope)
test_audit.pynames them.ICEBERG_EBS_AUDIT_RETENTION_DAYSis the natural shape if a deployment needs it.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