Skip to content

fix(hub): grant chat staging write by role, and take it back on demotion - #221

Open
aaron-tsar wants to merge 4 commits into
previewfrom
fix/chat-upload-role-write-preview
Open

aaron-tsar wants to merge 4 commits into
previewfrom
fix/chat-upload-role-write-preview

Conversation

@aaron-tsar

Copy link
Copy Markdown
Contributor

Server half of the chat-attachment fix. Pairs with drumee/schemas#182 and needs that one deployed first.

Cause

A member whose workspace role is Chat could not attach a file in chat. The upload answered 403 and the chat showed nothing — no attachment chip, no error.

An attachment stages in the hidden folder /__chat__/__upload__ before it becomes a message. A chat member carries privilege 7 and has no write bit for the workspace, which is intended, so the invite path grants write on that one folder with assign_via 'no_traversal' to keep the raised access from reaching anything inside it.

All three call sites granted a fixed value of 4. That meant write before the permission bits were renumbered (download 2 -> 4, write 4 -> 8) and means download now, so the grant no longer satisfied the upload ACL.

Changes

_grantMembership, invite_with_roles, and set_privilege now grant Privilege.WRITE, and only to a role that may chat.

The role gate is not a precaution. The grant was previously handed out regardless of role, so view-only members already carry the row — 22 of 197 on stage. Changing the value without gating would have handed an upload path to members entitled only to read. It is written at the call site rather than a layer above, using privilegeAllows(privilege, CAN_CHAT) from service/lib/member-capability.js, whose header explains why the bit is spelled out there rather than read from Constants.permission.

set_privilege also revokes on the way down. It only ever granted, so demoting a member from chat to view-only left the staging access in place and the demoted member kept uploading. A role change has to move the grant in both directions.

Verification

user_permission was probed in isolation on stage: a node grant of 15 against an account-wide 7 yields 15 on the staging folder, while the root folder and plain files stay at 7, and a node grant lower than the account-wide value does not lower it.

Still to run against this change, once schemas#182 is deployed: invite a chat member and a view-only member and check both, and demote a chat member and confirm the upload stops.

Noted, not fixed here

permission_grant refuses to write in a workspace where no member holds 63 on '*', and five such workspaces exist on stage — their owners carry 31 or 7. Every grant there rolls back as "New granting would create orphaned hub", so invitations and role changes fail in those workspaces too, silently. Separate issue.

The three call sites that give a new member access to the hidden chat
staging folder granted a fixed value of 4. That value meant write before
the permission bits were renumbered and means download now, so a member
whose role carries no write bit could never upload an attachment and got
a bare 403 with nothing in the UI to explain it.

Grant write instead, but only to a role that may chat. The grant was
previously unconditional, so a view-only member already carried it; left
unconditional it would have handed them an upload path once the value
became meaningful.

set_privilege also has to move the grant in both directions. It only ever
granted, so demoting a member from chat to view left the staging access
behind and the demoted member kept uploading. Revoke it there instead.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

… the package

Privilege.WRITE resolves to 15 under server-essentials 1.3.1 and to 7
under 1.3.6, which republished the pre-1.3.0 bit layout. package.json
asks for ^1.3.1, so a bare npm install picks up 1.3.6 and the grant would
carry no write bit at all -- the exact 403 this change exists to fix,
reintroduced by a dependency bump nobody would connect to chat.

Pin the value alongside the other capability bits, which are spelled out
for the same reason and say so in the file header.
@aaron-tsar

Copy link
Copy Markdown
Contributor Author

Amended: the grant value is no longer read from the package.

Privilege.WRITE resolves to 15 under server-essentials 1.3.1 and to 7 under 1.3.6, which republished the pre-1.3.0 bit layout (write back to 0b0000100). package.json asks for ^1.3.1 and only the lockfile holds 1.3.1, so a bare npm install picks up 1.3.6 and the grant would carry no write bit at all — the exact 403 this change exists to fix, reintroduced by a dependency bump nobody would connect to chat.

The value is pinned as CHAT_UPLOAD_GRANT in service/lib/member-capability.js, alongside the other bits that are spelled out there for the same reason.

Deploy precondition: install with npm ci, not npm install. Under 1.3.6 the ACL word src: "write" becomes the chat bit and the role model is wrong well beyond chat.

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

…at value

Both properties the attachment fix rests on can be undone by an edit that
reads like a simplification, and neither shows up as a test failure today.

The gate must refuse a view-only member. The grant went out regardless of
role before, so rows for view-only members already exist in the wild;
loosening the check to a partial bit overlap would admit exactly the role
it exists to exclude, because the chat constant in the package overlaps
read.

The granted value must carry the write bit and must not be read from the
package. server-essentials 1.3.6 republished the pre-1.3.0 layout, where
Privilege.WRITE is 7 and carries no write bit in this schema, and
package.json asks for ^1.3.1 — so a dependency bump nobody connects to
chat would silently restore the 403.

Reads the call sites as text rather than loading them, and imports only
member-capability, which requires nothing. That keeps it in the same
install-free workflow as the other regressions here.

Checked against three mutations: restoring the package value, dropping
the gate from one call site, and removing the revoke on demotion. Each
fails the suite.
@aaron-tsar

Copy link
Copy Markdown
Contributor Author

Checked the bot replies on this PR. One is real, one is not.

Not a finding: the SonarCloud quality gate. It fails on 0.0% Coverage on New Code (required ≥ 80%), but that is the repository's standing state, not something about this change. sonar-project.properties sets no sonar.javascript.lcov.reportPaths and no workflow generates a coverage report, so every PR here measures 0.0% — #218 merged with the gate red and #220 merged with the same 0.0% and a green gate. No test added to this branch can move that number. Configuring coverage collection is its own job and does not belong in a permission fix.

Also not findings: the Codex reviewer hit its usage limit and produced nothing, and the Claude action errored after 0s and 1s — infrastructure, no review either time.

Added anyway, because the coverage gate had a point even if the number is noise. This is a permission gate and it had no test. offline/test/chat-upload-grant.test.js locks the two properties the fix rests on, both of which an edit that reads like a simplification could undo:

  • the gate refuses a view-only member and admits every role from chat up — including the case that motivated privilegeAllows, where the package's chat constant overlaps read, so a loose & test admits the one role the gate exists to exclude;
  • the granted value carries the write bit and is not the value Privilege.WRITE resolves to under server-essentials 1.3.6.

It also reads the three call sites as text and asserts each grant sits behind a chat-bit check, and that set_privilege both grants and revokes.

Dependency-free, so it runs in the existing install-free workflow, and it is registered in test.yml. Checked against three mutations — restoring the package value, dropping the gate from one call site, removing the revoke on demotion — each of which fails the suite.

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@aaron-tsar

Copy link
Copy Markdown
Contributor Author

Closing in favour of #222, which targets test.

This one was opened against preview from a feature branch, and guard-preview-source rejects that — this repository only accepts a PR into preview from test. I carried the base-branch convention over from the schemas repository, where preview is the right target and no such guard exists. Wrong call: the two repositories do not share that rule, and the guard said so in 3 seconds.

Same three commits, rebased onto test, in #222.

@aaron-tsar aaron-tsar closed this Sep 17, 2026
@aaron-tsar aaron-tsar reopened this Sep 17, 2026
@aaron-tsar

Copy link
Copy Markdown
Contributor Author

Reopened on request, targeting preview directly.

The branch carries all three commits — the role-gated grant, the pinned grant value, and the regression test — and sits exactly three commits ahead of preview.

check-source will be red on this PR and that is expected. guard-preview-source requires a PR into preview to come from test, and this comes from a feature branch. The check is a branch-flow guard, not a statement about the change; nothing else here depends on it.

#222 carries the same three commits into test and is green, so both routes exist — merge whichever fits the release you are cutting, and close the other.

Either way this needs drumee/schemas#182 deployed first: the evaluator has to know how to read a node-scoped grant before the values this code writes mean anything.

…ed package

The note claimed a bare `npm install` would pick up server-essentials
1.3.6. It would not: package-lock.json pins 1.3.1 and is consistent with
the range in package.json, so npm installs the locked version. 1.3.6 has
been on the registry since 2026-06-15 and has never been installed here.

The hazard is narrower and worth stating as it is — anything that
re-resolves the range rather than reading the lock. Pinning the value is
still right; the reason given for it was overstated.
@aaron-tsar

Copy link
Copy Markdown
Contributor Author

Correction to something I wrote on this PR and in the code comment.

I said a bare npm install would pick up server-essentials 1.3.6 and strip the write bit, and called npm ci a deploy precondition. That is wrong. package-lock.json pins 1.3.1 and agrees with the ^1.3.1 range in package.json, so npm installs the locked version. 1.3.6 has been on the registry since 2026-06-15 and has never been installed here — three months of deploys through npm install prove the lock holds.

No change is needed to the deploy pipeline. The stage path (test → stage main) installs nothing at all, and the container path runs npm install, which honours the lock.

The hazard is narrower than I described: anything that re-resolves the range instead of reading the lock — a regenerated or deleted lockfile, or a bump that puts package.json and the lock out of step. Pinning CHAT_UPLOAD_GRANT is still the right call, because it takes chat attachments out of that blast radius entirely; the reason I gave for it was overstated.

The code comment is corrected in the commit above. Note that the copy now on test still carries the overstated wording — it will converge when these branches do, or I can push the same one-line fix there.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

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