fix(platform): gate conversation writes and audit 2FA successes - #3187
Merged
Conversation
Israeltheminer
force-pushed
the
fix/restore-role-and-audit-gates-v2
branch
from
September 3, 2026 17:50
dde2931 to
76b065b
Compare
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.
Israeltheminer
force-pushed
the
fix/restore-role-and-audit-gates-v2
branch
from
September 3, 2026 17:51
76b065b to
7c4083b
Compare
This was referenced Sep 3, 2026
This was referenced Sep 3, 2026
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.
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, whoseconversationsandconversationMessagesrules put each write throughauthorizeRlsat editor-or-above.viewerCanWritereuses that same matrix rather than declaring a second rolelist. It composes with the half that was correctly ported: a write door checks
the role first, then loads the row through
loadVisibleConversation, whichapplies
conversationAssignmentAllowsper row. Role decides whether you maychange 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
viewerIsAdminin the service, which is stricter than editor, so aroute-level editor check would loosen them.
The attachment-fetch door answers 501 today, but it is where the
Gmail/Outlook
get_attachmentsfetch lands, so it is gated now rather thanafter 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 actionsilently breaks any existing query or export that groups on it. Checked
against 0.4's full vocabulary:
passkey_removedandpasskey_revoked_by_adminwere distinct events there and stay distinct here.Tests
Three layers, because they fail on different mistakes.
viewerCanWritepinned across all 8 role valuesAt
member, every door answers 403 and no service function is reached — therefusal lands before the handler can touch state. At
editor, no door returnsthe 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:
POST /:id/replyviewerIsAdminfromassignConversationexpected 0 to be 14)integration-check.tswalks all twelve doors end to end atmemberexpecting403 and confirms the thread is untouched, then flips the member row to
editorand re-walks. I could not observe that lane: the suite aborts earlyon the knowledge/embedding step, which needs
KNOWLEDGE_DATABASE_URLand anS3 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
authorizeRlstoisAdminRole. That is now redundant: #3159 landed the same role matrix, andmain's doors already call
authorizeRls(role, 'auditLogs', 'read'), whichthat 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.tsandaccess.test.tscame out of this branch empty for the samereason — 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_scrubbedrecompute skip and its oldest-row trustanchor are untouched; both are signed off in
backend/MIGRATION.mdasintentional divergences, so they are a design question rather than a porting
defect.
Gate
typecheck0 errors,oxlint --type-awareclean,oxfmt --checkclean,conversations + auth + two_factor suites 98 passed (13 files).