Skip to content

fix(platform): gate conversation writes and audit 2FA successes - #3187

Merged
Israeltheminer merged 1 commit into
mainfrom
fix/restore-role-and-audit-gates-v2
Sep 3, 2026
Merged

fix(platform): gate conversation writes and audit 2FA successes#3187
Israeltheminer merged 1 commit into
mainfrom
fix/restore-role-and-audit-gates-v2

Conversation

@Israeltheminer

Copy link
Copy Markdown
Collaborator

Two authorization and audit behaviours the port dropped. A read-only member
could send outbound mail under the organization's name; an attacker who added
a passkey and disabled 2FA left no successful-action trail.

Refs #3142. Supersedes #3149, which was 35 commits behind main.

A read-only member could write to conversations

Reply, send outbound mail, close, bulk-act, delete, and act on individual
messages. 0.4 ran every conversation mutation through mutationWithRLS, whose
conversations and conversationMessages rules put each write through
authorizeRls at editor-or-above.

viewerCanWrite reuses that same matrix rather than declaring a second role
list. It composes with the half that was correctly ported: a write door checks
the role first, then loads the row through loadVisibleConversation, which
applies conversationAssignmentAllows per row. Role decides whether you may
change a conversation; assignment decides which ones you can see at all, and
is stricter still.

Twelve doors carry the check, as the handler's first statement — before the
body parse, so a refusal never depends on the payload.

The two assignment doors are the deliberate exception: they gate on
viewerIsAdmin in the service, which is stricter than editor, so a
route-level editor check would loosen them.

The attachment-fetch door answers 501 today, but it is where the
Gmail/Outlook get_attachments fetch lands, so it is gated now rather than
after that wiring makes it live. If you would rather keep it a pure read,
say so and I will drop that one line.

2FA and passkey events audited only on failure

All five events now audit, inside the same serializable transaction as the
state change.

The 0.4 action names are reused verbatim — 2fa_enrolled, 2fa_disabled,
passkey_added, passkey_removed, passkey_sign_in. A renamed action
silently breaks any existing query or export that groups on it. Checked
against 0.4's full vocabulary: passkey_removed and
passkey_revoked_by_admin were distinct events there and stay distinct here.

Tests

Three layers, because they fail on different mistakes.

Layer Catches
viewerCanWrite pinned across all 8 role values a matrix edit that widens the gate by accident
12 doors driven through the real handlers the gate present but deciding wrongly
static read of the route table a NEW door shipping ungated

At member, every door answers 403 and no service function is reached — the
refusal lands before the handler can touch state. At editor, no door returns
the gate's refusal envelope, and the four doors whose services are mocked run
all the way through (undo/retry/discard 200, attachments its own honest 501).

The third layer exists because a gate applied door by door invites the next
door to ship ungated. The assignment pair is allowlisted there, and a
companion assertion pins their service-side admin check so the allowlist
cannot quietly become a hole.

Each assertion was mutation-tested:

Mutation Went red
un-gate the attachment door static check, naming the door
un-gate POST /:id/reply static check and that door's behavioural test
drop viewerIsAdmin from assignConversation the allowlist companion assertion
break the route-matching pattern the door-count floor (expected 0 to be 14)

integration-check.ts walks all twelve doors end to end at member expecting
403 and confirms the thread is untouched, then flips the member row to
editor and re-walks. I could not observe that lane: the suite aborts early
on the knowledge/embedding step, which needs KNOWLEDGE_DATABASE_URL and an
S3 endpoint this machine has no MinIO for. The three unit layers above are
what I actually ran.

The audit-log third is dropped

The original #3149 also swapped the six audit-log doors from authorizeRls to
isAdminRole. That is now redundant: #3159 landed the same role matrix, and
main's doors already call authorizeRls(role, 'auditLogs', 'read'), which
that matrix denies for developer, editor and member. Restating the rule as a
second check at each door would be a divergent copy of a concept that already
has one home.

access.ts and access.test.ts came out of this branch empty for the same
reason — main already has them.

Scope

Retention destruction still emits no audit rows — sixteen 0.4 action names
exist nowhere in the tree. Same class, different file.

The audit chain's pii_scrubbed recompute skip and its oldest-row trust
anchor are untouched; both are signed off in backend/MIGRATION.md as
intentional divergences, so they are a design question rather than a porting
defect.

Gate

typecheck 0 errors, oxlint --type-aware clean, oxfmt --check clean,
conversations + auth + two_factor suites 98 passed (13 files).

Two authorization and audit behaviours the port dropped.

## A read-only member could write to conversations

Reply, send outbound mail, close, bulk-act, delete, and act on individual
messages. 0.4 ran every conversation mutation through `mutationWithRLS`,
whose `conversations` and `conversationMessages` rules put each write
through `authorizeRls` at editor-or-above.

`viewerCanWrite` reuses that same matrix rather than declaring a second
role list. It composes with the half that was correctly ported: a write
door checks the role first, then loads the row through
`loadVisibleConversation`, which applies `conversationAssignmentAllows`
per row. Role decides whether you may change a conversation; assignment
decides which ones you can see at all, and is stricter still.

Twelve doors carry the check, as the handler's first statement — before the
body parse, so a refusal never depends on the payload. The two assignment
doors are the deliberate exception: they gate on `viewerIsAdmin` in the
service, which is stricter than editor, so a route-level editor check
would loosen them.

Sending outbound mail is the sharpest of the twelve — it leaves the
building under the organization's name. The attachment-fetch door answers
501 today, but it is where the Gmail/Outlook `get_attachments` fetch lands,
so it is gated now rather than after that wiring makes it live.

## 2FA and passkey events audited only on failure

An attacker who added a passkey and disabled 2FA left no successful-action
trail. All five events now audit, inside the same serializable transaction
as the state change.

The 0.4 action names are reused verbatim — `2fa_enrolled`, `2fa_disabled`,
`passkey_added`, `passkey_removed`, `passkey_sign_in`. A renamed action
silently breaks any existing query or export that groups on it. Checked
against 0.4's full vocabulary: `passkey_removed` and
`passkey_revoked_by_admin` were distinct events there and stay distinct.

## Tests

Three layers, because they fail on different mistakes:

- `viewerCanWrite` is pinned across the whole role vocabulary, so a matrix
  edit that widens it by accident cannot pass unnoticed.
- The twelve doors are driven through the real handlers: a `member` gets
  403 and no service function is reached; an `editor` never sees the gate's
  refusal envelope, and the four doors whose services are mocked run all
  the way through.
- A gate applied door by door invites the next door to ship ungated, so a
  static check reads the route table and fails when a POST, PATCH or DELETE
  handler has no role check on its first line. The assignment pair is
  allowlisted there, and a companion assertion pins their service-side
  admin check so the allowlist cannot become a hole.

`integration-check.ts` walks all twelve doors end to end at `member`
expecting 403 and confirms the thread is untouched, then flips the row to
`editor` and re-walks.

The audit-log third of this change is dropped: #3159 landed the same role
matrix, and main's doors already call `authorizeRls(role, 'auditLogs',
'read')`, which that matrix now denies below admin. Restating the rule as
a second `isAdminRole` check at each door would be a divergent copy.
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.

1 participant