feat: promote MinimumImplicitMember experiment to GA - #27472
Conversation
Documentation CheckThis PR promotes the Updates Needed
New Documentation Needed
All previously identified documentation gaps are now addressed. Automated review via Coder Agents |
<!-- Authored with Coder Agents on behalf of @Emyrk --> Finishes the experiment removal on top of #27472: - Deletes `DeviationRejectedWithoutExperiment` (it asserted the removed 403 gate and would fail), renames the surviving subtests, and drops the now-pointless `dv.Experiments` setup. - Adds `TestMemberWorkspaceElevation` as a witness that the workspace-ops elevation stays off `organization-member`/`organization-service-account` and lives on `organization-workspace-access`. - Frontend: removes both `experiments.includes("minimum-implicit-member")` sites and the whole `defaultRolesEnabled` prop plumbing (pages, view, stories) — required once codegen drops the value from the `Experiment` union. - `make gen`: apidoc, `typesGenerated.ts`, feature-stages/schemas docs. Verified: `go build ./...`, `TestPatchOrganizationsByUser` + `TestMemberWorkspaceElevation` pass, `tsc --noEmit` clean. <sub>Coder Agents on behalf of @Emyrk.</sub>
Docs previewCheck off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here. |
<!-- Authored with Coder Agents on behalf of @Emyrk --> Finishes the experiment removal on top of #27472: - Deletes `DeviationRejectedWithoutExperiment` (it asserted the removed 403 gate and would fail), renames the surviving subtests, and drops the now-pointless `dv.Experiments` setup. - Adds `TestMemberWorkspaceElevation` as a witness that the workspace-ops elevation stays off `organization-member`/`organization-service-account` and lives on `organization-workspace-access`. - Frontend: removes both `experiments.includes("minimum-implicit-member")` sites and the whole `defaultRolesEnabled` prop plumbing (pages, view, stories) — required once codegen drops the value from the `Experiment` union. - `make gen`: apidoc, `typesGenerated.ts`, feature-stages/schemas docs. Verified: `go build ./...`, `TestPatchOrganizationsByUser` + `TestMemberWorkspaceElevation` pass, `tsc --noEmit` clean. <sub>Coder Agents on behalf of @Emyrk.</sub>
bc95327 to
406bb94
Compare
|
Rebased onto
Verified locally: Generated by Coder Agents on behalf of @Emyrk. |
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 32 findings (1 P1, 3 P2, 14 P3, 1 P4, 8 Nit, 5 Note), REQUEST_CHANGES. Review Finding inventoryFinding inventory: PR #27472Findings
Contested and acknowledgedNone. Severity decisions (drop/downgrade gate)Every downgrade below has its keep-argument written first, per the gate.
Cross-check: contradictions and interactions resolved
Round logRound 1Netero first pass: 3 P3, 5 Nit, 2 Note, no P0-P2, so the panel ran in the same round. Law not spawned (effective additions 61, below the 1000 threshold). Panel: bisky, hisoka, mafu-san, mafuuu, pariston, komugi, kurapika, razor, kite, knov, ging-go, ging-ts, ging-react, gon, leorio, nami, ryosuke, chopper, meruem, melody, luffy, plus wildcards zoro and takumi. Totals after cross-check: 1 P1, 6 P2, 14 P3, 1 P4, 8 Nit, 5 Note. Three process findings raised in the review body. Reviewed against 6e5f02b..406bb94. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
This is a good deletion. Net -138 lines, the minimumImplicitMember atomic.Bool mirror is gone from object.go, and OrgMemberPermissions / OrgServiceAccountPermissions are now pure functions of OrgSettings instead of reading process-global state on every call. RoleOptions is left describing only deploy-time deny toggles, and the startup ordering dependency between ReloadBuiltinRoles and ReconcileSystemRoles disappeared with the global.
The panel independently traced the two things that could have broken, and neither did. Cross-role negation: the workspace:share deny stays on organization-member while the positive workspace grant moved to organization-workspace-access, and check_all_org_permissions in policy.rego:120-133 votes across all of a subject's roles at a level, so the deny still overrides the allow in the other role. Upgrade path: migration 000516 unconditionally backfills every existing org, the column is NOT NULL with no SQL default so no insert path can silently omit it, ReconcileSystemRoles runs after ReloadBuiltinRoles in the same New(), and both role-union queries include the column for service accounts too. Deployments still passing --experiments=minimum-implicit-member warn and boot. Repo-wide greps for the removed identifier return nothing.
Round 1 count: 1 P1, 6 P2, 14 P3, 1 P4, 8 Nit, 5 Note. Three of the P2s are process findings and are below rather than inline.
The P1 and most of the P2s are not about the RBAC edit. They are about the surface the experiment flag was hiding. Seven findings land on the Default Roles editor and the member-roles dialog, and the chain is worse than any single one: the copy misstates the blast radius (CRF-11) in a term no operator can resolve (CRF-12), the dialog offers organization-admin as an org-wide default (CRF-22), an overlapping explicit grant then becomes unremovable from the members page (CRF-13), and a viewer without editSettings cannot see any of it (CRF-25). They are filed separately because the fixes are independent, but they ship together.
Process, three items:
CRF-15 (P2, verification scope). Commit a31ad5de1f states "Verified: go build ./..., TestPatchOrganizationsByUser + TestMemberWorkspaceElevation pass, tsc --noEmit clean" while go test ./coderd/rbac/ was red at that commit. Mafu-san ran both directions: at a31ad5de1f, TestAuthorizeDomain/Member/Set0/Case2 fails with rbac: forbidden on ssh and stop, package FAIL in 1.09s; at the merge base 6e5f02bfbe the same run is ok. The named test lives in the same package as the failing one, so the claim reports the result of a -run filter inside a red package as verification of the change. The fixtures that fix it land one commit later, so head is green. The standard for the rest of this PR: when a change edits a coderd/rbac permission set, the minimum evidence is go test ./coderd/rbac/ with no -run filter. Related, Kite: taken alone, 0e36260b2c deletes TestMinimumImplicitMember and ships the unconditional elevation removal with nothing asserting it, and a31ad5de1f adds TestMemberWorkspaceElevation back. A squash merge makes that moot; a merge commit does not.
CRF-16 (P2, emdash in commit metadata). a31ad5de1f's message body contains a U+2014 in "...(pages, view, stories) — required once codegen drops the value...". AGENTS.md forbids U+2014 outright, and scripts/check_emdash.sh scans git ls-files, so it has no reach into commit messages: make lint/emdash is clean. Rewrite the body before merge. The rule applies to commit and PR text, not only tracked files.
CRF-28 (P2, empty PR description). The body is the empty string for a change that permanently alters the default permission set of every deployment on upgrade. The permanent record becomes one line naming a Go identifier: it does not say that organization-member stops granting workspace access, that the elevation moves to organization-workspace-access, that 000516 plus ReconcileSystemRoles covers the upgrade, or that org admins can now clear default_org_member_roles without a flag. Write it. While there, the release notes should name the removed experiment value: operators who set it keep logging "ignoring unknown experiment" on every restart with nothing telling them to remove it. And Leorio on the subject line: "promote MinimumImplicitMember experiment to GA" names an internal Go field and describes the process, not the effect.
Two things the panel tried hard to break and could not, recorded so they are not re-litigated: no production code path builds a member subject outside the SQL union (coderdtest.go:933-943 and idpsync/role.go:182-204 already mirror it explicitly), and the removed 403 left no ungated path, because PATCH /organizations/{organization} sits behind RequireFeatureMW(FeatureMultipleOrganizations), dbauthz.UpdateOrganization runs canAssignRoles over the delta, the handler still rejects non-built-in names, and enterprise/audit/table.go:361 tracks the column. Kite's praise on TestMemberWorkspaceElevation stands too: replacing a toggle test with an invariant test, and updating the fixtures to keep their effective permissions equal to what a real member gets rather than weakening the assertions, is the harder and correct direction.
Hisoka, on the whole thing: "I came looking for a fight. ♦ ... Rating: ★ curiosity only. I wanted a ★★★★. You didn't give me one."
site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx:154
P1 [CRF-11] The Default Roles copy tells the admin an empty selection affects "new members", but clearing the list revokes workspace access from every existing member of the org on their next request. (Kite P1, Leorio P1, Pariston P2, Razor P2, Chopper P2, Luffy P2)
Six reviewers reached this independently. The section contradicts itself in one paragraph, and the wrong half is the one an admin reads while deciding whether to confirm:
CustomRolesPageView.tsx:154: "Roles attached to every member of this organization. An empty selection limits new members to the floor permissions only."CustomRolesPageView.tsx:169: "No default roles. New members receive only the floor."DefaultRolesDialog.tsx:80: "These roles are attached to every member of this organization. Use an empty selection to grant new members only the floor."
The behavior is settled, not inferred. Pariston: "TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles at coderd/database/querier_test.go:3301 shrinks an org's defaults to [] and asserts the scoped organization-workspace-access role disappears from an already-existing member's effective roles, and from an existing service account's." users.sql:637 unions the column at request time, and 000516_org_default_member_roles.up.sql states "changes propagate to all members on the next request".
Kite traced how far the loss reaches: "The site member role's user permissions are allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ...) (coderd/rbac/roles.go:437), so a user's access to their own workspaces comes only from the org elevation that organization-workspace-access now carries exclusively." Luffy states the outcome: "Their workspaces keep running and they can't touch them."
This PR is what makes the copy load-bearing. showDefaultRoles dropped defaultRolesEnabled (:63) and the 403 on deviation was deleted from enterprise/coderd/organizations.go, so every deployment entitled for multiple_organizations now has a one-click path from a settings page to revoking read, ssh, start, stop and delete on running workspaces for every member of an org.
P1 rather than P2, against the four reviewers who said P2: their reason for P2 was that an attentive admin may notice the contradiction, which is a mitigation that depends on the reader catching the page lying to them. The consequence does not: it is an org-wide authorization revocation triggered from a settings control that describes it as a forward-only default, with no confirmation of scope. Reversible by re-adding the role, but only for someone who already knows the roles are unioned per request.
Fix all three strings, not one. Leorio's draft for the dialog: "These roles are added to every member of this organization, including existing members, and take effect on their next request. Removing organization-workspace-access revokes workspace create, build, ssh, and delete for every member who is not granted it directly." Kite's stronger option, worth taking: have DefaultRolesDialog warn on confirm when the submitted set drops organization-workspace-access while the org has members, naming the count. The copy fix removes the false statement; the warning is what makes the blast radius visible at the moment of the click.
🤖
site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx:154
P2 [CRF-12] The same three strings explain the feature with "the floor", an rbac package term that is defined nowhere an operator can reach, and this PR deleted the last place in the tree that defined it. (Gon P2, Leorio P2, Mafuuu P3, Luffy P3, Zoro P3)
Separate from CRF-11: fixing the "new members" claim does not remove the jargon, and removing the jargon does not fix the claim.
Leorio: "Grep the whole repo: 'floor' in this sense is defined nowhere. This PR deleted the only two places that explained it, the RoleOptions.MinimumImplicitMember doc comment and the elevation block in OrgMemberPermissions. The UI now uses a term whose definition the same commit removed." Luffy grepped the docs for somewhere to send the reader: "the only hits are the generated API reference (docs/reference/api/*.md), and 'Gateway Account' appears nowhere in docs/."
The consequence is the question the copy fails to answer. Mafuuu: "an org admin about to strip workspace access from every member of an organization is told the outcome in a word that has no referent they can resolve."
P2 over the three P3s: the P2 arguments are the more specific ones, naming all three strings, quoting them, and showing that the definition left the tree in this commit. Note this is not CRF-3 with different words. CRF-3 is the missing prose page; this is the shipped dialog standing in for it, and Zoro is right that even with a doc, "the floor" is the wrong word in a settings page.
Zoro's concrete replacements, for :154: "Roles attached to every member of this organization. With an empty selection, new members can read the organization and manage their own account, but cannot create or use workspaces." And for :169: "No default roles. New members cannot create or use workspaces." Fold in CRF-11's correction about existing members and both findings close together.
🤖
coderd/rbac/roles.go:1133
P2 [CRF-14] The PR deleted the only in-file explanation of where member workspace permissions went, and put nothing in its place. (Leorio P2, Gon P3)
Leorio: "Before this PR, a reader who opened OrgMemberPermissions hit an eleven-line comment naming OrgWorkspaceAccessMemberPerms, organization-workspace-access, and default_org_member_roles. The PR removed it (-roles.go:1199-1211) and put nothing in its place." What remains says only that the permissions vary with workspace sharing settings.
The consequence is a comprehension trap in the most widely held role in the system: "The next person who gets a bug report saying 'members can't create workspaces' reads this function, sees no ResourceWorkspace grant, and has no idea whether that is the design or the bug."
This is the substantive half of the comment findings, distinct from CRF-5 (stale "in the floor" wording) and CRF-29 (comments restating their assertions). Leorio's draft:
// OrgMemberPermissions returns the permissions for the organization-member
// system role, which can vary based on the organization's workspace sharing
// settings.
//
// Workspace permissions are deliberately not included here. They live on the
// organization-workspace-access role (OrgWorkspaceAccessMemberPerms), which
// DefaultOrgMemberRoles attaches to every new organization. An org that clears
// default_org_member_roles leaves its members with this set only.OrgServiceAccountPermissions has the same hole and needs the same sentence.
🤖
coderd/rbac/roles.go:227
P3 [CRF-18] OrgWorkspaceAccessMemberPerms stays exported although the only production caller left is the role definition in the same package, so the elevation this PR just unbundled can be re-bundled into any other role. (Knov)
Knov: "Before this PR the function had three production callers: the orgWorkspaceAccess role at coderd/rbac/roles.go:720, and the two slices.Concat(elevation, floor) sites in OrgMemberPermissions and OrgServiceAccountPermissions. After the diff, grep -rn 'OrgWorkspaceAccessMemberPerms' coderd enterprise cli --include='*.go' | grep -v _test.go returns only the definition and line 720, both inside package rbac."
The consequence is the misuse the promotion exists to remove: "a future caller writing Member: slices.Concat(rbac.OrgWorkspaceAccessMemberPerms(), somethingElse) restores implicit workspace access without touching organization-member, and nothing fails."
Ordered with CRF-17, because it is one change: fix the test to read the role through RoleByName first, then unexport the helper to orgWorkspaceAccessMemberPerms. The elevation becomes reachable only through the role identifier, and the package boundary states the invariant the PR title only asserts in prose. Kept at P3 rather than treated as dead-code cleanup: an exported perm-set helper is reachable by any package, and the severity is set by what happens when someone uses it, not by there being no caller today.
🤖
codersdk/organizations.go:107
P3 [CRF-21] GA ships no way to create a Gateway Accounts organization atomically: default_org_member_roles is settable only on PATCH, so every new org is born granting workspace access to all members. (Mafuuu)
Mafuuu: "CreateOrganizationRequest (codersdk/organizations.go:107-113) has no default_org_member_roles field, and enterprise/coderd/organizations.go:316 hardcodes DefaultOrgMemberRoles: rbac.DefaultOrgMemberRoles() on insert. An operator who wants an org whose members must never hold workspace permissions has to do create-then-PATCH, and the org exists with the elevation in between."
The scenario: an operator provisions a gateway org from Terraform or a script, POST returns, and before the follow-up PATCH lands an IdP-sync-eligible user logs in and is enrolled. That user can create and exec into workspaces in an org created specifically to deny it. The subsequent PATCH closes the door but does not revoke what was already reachable.
P3 because the window is short and needs a login inside it. It is a finding rather than a note because GA is the moment this API shape stops being provisional: CreateOrganizationRequest gains a *[]string now or the gap is permanent. The fix is the field plus threading it into the existing InsertOrganizationParams, and the validation loop at enterprise/coderd/organizations.go:68-81 is already factored to be reused.
Mafuuu states the limit of the evidence: derived from reading the request shapes, the insert call site and the request-time union at users.sql:625, not from constructing the race.
🤖
site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx:161
P3 [CRF-26] Opening "Edit default roles" before the org roles query resolves gives the admin a dialog with a title, a Confirm button, and nothing in between. (Nami)
Nami: "The Edit button is disabled only for isUpdatingDefaultRoles || !defaultRolesEntitled, so it is live while availableOrgRoles is still undefined. DefaultRolesDialog.tsx:39 coerces that to [], and RoleSelector.tsx:67 returns null when selectableRoles is empty, so the dialog body is empty. Confirm still submits [...selected], which equals the current roles, so nothing is destroyed; the admin just gets a headless dialog and no explanation."
Nothing is lost, which is why this is P3 and not higher, but the empty dialog is indistinguishable from Bisky's saturation case in CRF-13, so an admin who hits either reports the same symptom and the two have different causes.
RoleSelector already accepts loading and error, and CustomRolesPage already holds organizationRolesQuery. Either disable the button until availableOrgRoles is defined, or thread the query's loading and error state through to the selector. Threading it also covers the load failure, which currently renders the same empty dialog.
🤖
enterprise/coderd/license/usercount_test.go:32
P3 [CRF-1] The file still declares that subtests toggle global builtin roles and keeps them serial, but after this PR every call is ReloadBuiltinRoles(nil). (Netero)
Netero: "All six sites (:69/:70, :115/:116, :157/:158, :188/:189, :231/:232, :256/:257) now pass nil on both the set and the cleanup, and grep -rn ReloadBuiltinRoles enterprise/coderd/license/ shows no caller in the package that passes options any more (the bench file's call was deleted in this diff). So the reset is a no-op and nothing in the package mutates the globals."
Two consequences: "the header comment at :32-33 tells the next reader that role globals vary per subtest when they do not, and the //nolint:tparallel,paralleltest at :35 permanently serializes six subtests that each stand up a Postgres database."
Fix: delete the six reload/cleanup pairs, the :32-33 paragraph and the nolint, then add t.Parallel() to the parent and subtests. CRF-32 is the seventh pair, in roles_test.go, created by this same diff; treat them as one change. Razor checked the assertions do not go vacuous: every subtest that depends on floor-only members calls emptyDefaultRoles, so the no-op reloads leave nothing hanging.
🤖
coderd/rbac/roles_test.go:218
Nit [CRF-30] Hand-rolled hasResource loop where slices.ContainsFunc does the job, in a file that already uses that pattern. (Ging-Go)
Ging: "The PR rewrote this test body and kept the six-line closure. permissionGranted at coderd/rbac/roles_test.go:57 in the same file is the established pattern":
hasResource := func(perms []rbac.Permission, resource string) bool {
return slices.ContainsFunc(perms, func(p rbac.Permission) bool {
return p.ResourceType == resource && !p.Negate
})
}slices is already imported here. Keep the !p.Negate filter exactly as is: Chopper and Kite both verified it is load-bearing, because OrgMemberPermissions appends a negated ResourceWorkspace/ActionShare permission when ShareableWorkspaceOwners != Everyone, and without the filter the require.False would flip for any other org setting.
🤖
coderd/rbac/roles.go:1066
P4 [CRF-31] sort.Slice in Permissions, the function this PR now routes both OrgMemberPermissions and OrgServiceAccountPermissions through directly. (Ging-Go)
Ging: "slices.SortFunc (1.21) is the replacement; the sort package docs point at it ('more ergonomic and runs faster', no reflection-based swapper). Same instability semantics, so the sort result is unchanged. Notable only because this PR deleted \"slices\" from this file's imports (line 6 still imports sort), leaving the file on the older API for its one sort."
Outside the diff, so P4. Worth knowing the instability is deliberate downstream: Komugi notes ReconcileSystemRole compares with the set-based rbac.PermissionsEqual, precisely so unstable tie order among a resource's actions cannot make every replica rewrite the system-role row at startup.
🤖
coderd/rbac/rolestore/rolestore.go:71
Note [CRF-34] A stale name in default_org_member_roles degrades quietly rather than failing loudly, and that string is now the only grant path. (Meruem)
Meruem: "Expand silently drops roles that no longer resolve. If a future release renames or removes organization-workspace-access, orgs storing that name lose the grant with no error surfaced anywhere. That is the failure mode a string-keyed cross-version reference buys, and it is now the only mechanism granting workspace access."
Not this PR's to fix. The GA promotion is what makes it permanent, which is why it is on the record.
🤖
coderd/rbac/roles.go:221
Note [CRF-35] DefaultOrgMemberRoles() is applied only at org creation, so the deployment default and the per-org data diverge permanently. (Pariston)
Pariston: "enterprise/coderd/organizations.go:316 passes it on insert and migration 000516 backfilled it once. There is no reconciler that reapplies it. Any future addition to the deployment-wide default will need its own backfill migration to reach existing orgs."
This is the base design that shipped under the experiment, not something this PR introduces, and no change is requested. It is the durable cost of moving the elevation from code into per-org data, and the reason the next change to DefaultOrgMemberRoles() is a migration, not a constant edit.
🤖
🤖 This review was automatically generated with Coder Agents.
| organization?.default_org_member_roles, | ||
| organizationRolesQuery.data, | ||
| ]); | ||
| }, [organization?.default_org_member_roles, organizationRolesQuery.data]); |
There was a problem hiding this comment.
P2 [CRF-13] Implied default roles are filtered out of the checkbox list, so any role that sits in default_org_member_roles and is also granted explicitly to a member becomes unremovable from the members page. (Nami P2, Bisky P4)
Dropping the experiment guard makes defaultMemberImpliedRoles resolve on every deployment, and 000516 backfills ['organization-workspace-access'] into every existing org, so the list is non-empty everywhere. I verified the mechanism in the orchestrator worktree. Nami's trace:
site/src/modules/roles/RoleSelector.tsx:59-63filters implied role names out ofavailableRolesbefore grouping, so implied roles appear in neitherselectableRolesnoradvancedRoles. [...]RoleSelectorDialog.tsx:68seedsselectedRolesfrom the member's explicit roles and line 101 submits[...selectedRoles]verbatim. A hidden explicit grant is therefore re-submitted on every save. [...]coderd/members.go:442passesparams.Rolesstraight toUpdateMemberRoleswith no dedupe against the org defaults, so the grant persists silently.
The filter at :60 is unconditional on selectedRoles, so an explicitly held role loses its checkbox purely because the org also grants it by default. The reachable sequence is all UI: drop the role from the defaults, grant it explicitly to one member, re-add it to the defaults. That member's row shows the role, their dialog has no way to clear it, and every save re-sends it. Same shape for organization-admin: while it sits in the default list, an explicitly granted org admin cannot be demoted from the members page.
Bisky reports the coverage half at P4: WithAdditionalImpliedRoles (RoleSelector.stories.tsx:92-100) renders the path with no play, and passes implied roles that are not in selectedRoles, so the overlap is untested and "a regression that puts organization-workspace-access back in the checkbox list changes pixels, not test results". Bisky also names a second reachable state on the same filter: when the defaults cover every assignable org role, selectableRoles is empty and RoleSelector returns null at :67, which an admin reports as "the role dialog is empty".
Nami's fix, in the page rather than the shared selector: pass only the implied roles the member does not already hold explicitly, so an explicit grant keeps its checkbox. If you prefer the selector, keep a role in selectableRoles when selectedRoles.has(role.name) and caption it. Either way add the play assertion for the overlap, because without it the fix has no witness.
Nami read the four files and did not execute a probe (the browser-mode run never connected), so treat the mechanism as traced, not reproduced. I confirmed RoleSelector.tsx:58-69 matches the description.
🤖
| require.True(t, hasResource(sa, floorWitness), "organization-service-account should still include the floor") | ||
| }) | ||
| // The elevation is available via organization-workspace-access. | ||
| require.True(t, hasResource(rbac.OrgWorkspaceAccessMemberPerms(), elevationWitness), "organization-workspace-access should carry the elevation") |
There was a problem hiding this comment.
P3 [CRF-17] The new assertion checks OrgWorkspaceAccessMemberPerms against a witness defined as "whatever that helper grants", so it cannot fail and never touches the role the comment names. (Meruem P3, Leorio P3, Knov P3, Melody Nit, Gon P2)
Five reviewers landed on this line from different directions. Meruem: "elevationWitness is ResourceWorkspace.Type, chosen at line 230 because OrgWorkspaceAccessMemberPerms grants it. Line 247 then asserts OrgWorkspaceAccessMemberPerms() contains it. The assertion cannot fail unless someone changes both the helper and the witness, and it never touches the organization-workspace-access role."
The part left unverified is exactly the contract the test's own doc states. Meruem: "if the orgWorkspaceAccess builder at coderd/rbac/roles.go:712 stopped wiring OrgWorkspaceAccessMemberPerms() into ByOrgID[org].Member, this test would still pass while every member in every org lost workspace access." Gon separately flags the comment above it as pure restatement; it is worse than that, because it promises something the line does not check.
Assert through the role, which is what default_org_member_roles actually names:
orgID := uuid.New()
wsAccess := must(rbac.RoleByName(rbac.ScopedRoleOrgWorkspaceAccess(orgID)))
require.True(t, hasResource(wsAccess.ByOrgID[orgID.String()].Member, elevationWitness))Melody adds the second half worth asserting here: require.Contains(t, rbac.DefaultOrgMemberRoles(), "organization-workspace-access").
Held at P3 against Melody's Nit and three Notes. Coverage does exist elsewhere, and the reviewers found it rather than assuming: TestRolePermissions drives orgWorkspaceAccessUser through the authorize matrix (roles_test.go:316), httpmw/authorize_test.go:58,71 seeds an org with rbac.DefaultOrgMemberRoles() and asserts the extracted actor holds the scoped role, and usercount_test.go "DefaultRolesParity" counts a role-less member as workspace-capable purely through the request-time union. That is mitigation, not defensibility. Bisky put it best: "there is no coverage hole; there is a line that reads like coverage and isn't", in the one test whose stated job is proving the elevation is reachable.
🤖
| } | ||
| // organization-workspace-access carries the workspace perms; the | ||
| // organization-member role alone is only the floor. | ||
| wsAccess, err := rbac.RoleByName(rbac.ScopedRoleOrgWorkspaceAccess(orgID)) |
There was a problem hiding this comment.
P3 [CRF-19] "What roles does an org member implicitly hold" now has five hand-maintained implementations and no Go owner; this PR is the bill for that. (Ryosuke P3, Zoro P3, Kite Nit)
Ryosuke enumerated them: GetAuthorizationUserRoles and GetActiveUsersAuthorizationRoles (parity enforced by TestGetActiveUsersAuthorizationRolesParity), coderd/idpsync/role.go:192-196, coderdtest.go:932-944, and now this helper. "The SQL pair has an owner. The three Go copies do not. [...] the elevation moved, and the fix was to hand-patch one more copy plus seven fixtures in authz_internal_test.go."
The failure mode is asymmetric, which is what makes it worth more than a style note: "A hand-rolled subject that is too weak turns a permit-assertion red, which CI catches. A deny-assertion keeps passing for the wrong reason, which CI does not catch, and the test then proves nothing about the role model it claims to test. watcher_test.go is a watcher permission test, so it holds both kinds."
Zoro found that the codebase already states the obligation these copies violate, at coderdtest.go:933-943: "The implicit role set (organization-member plus the org's default_org_member_roles) is unioned at request time by GetAuthorizationUserRoles. Subjects built directly here bypass that SQL union, so mirror it explicitly." That call site mirrors it generically by iterating org.DefaultOrgMemberRoles; the eight new fixtures name the role literally.
Two scopes, and Zoro argues the tradeoff honestly. Minimum, in this fixture, which is the one claiming to model "a basic org member" (Kite and Zoro converge on the same loop):
roles := rbac.Roles{memberRole, orgMember}
for _, name := range rbac.DefaultOrgMemberRoles() {
r, err := rbac.RoleByName(rbac.RoleIdentifier{Name: name, OrganizationID: orgID})
if err != nil {
panic(err)
}
roles = append(roles, r)
}Leave authz_internal_test.go explicit: Zoro notes that :802 and :1306 deliberately keep floor-only subjects, and a helper there would hide which subjects hold the elevation. Ryosuke's structural alternative, preserved at his severity rather than trimmed to the fixture fix: name the set once as rbac.ImpliedOrgMemberRoles(org) and have idpsync, coderdtest.AuthzUserSubject and this helper call it, so the next promotion like this one touches one function instead of four files.
🤖
| ) | ||
|
|
||
| if bool(options.DeploymentValues.DisableOwnerWorkspaceExec) || bool(options.DeploymentValues.DisableWorkspaceSharing) || bool(options.DeploymentValues.DisableChatSharing) || experiments.Enabled(codersdk.ExperimentMinimumImplicitMember) { | ||
| if bool(options.DeploymentValues.DisableOwnerWorkspaceExec) || bool(options.DeploymentValues.DisableWorkspaceSharing) || bool(options.DeploymentValues.DisableChatSharing) { |
There was a problem hiding this comment.
P3 [CRF-20] The guard deciding whether to call ReloadBuiltinRoles is a hand-maintained mirror of the RoleOptions field set, so the two can drift silently. (Meruem)
Meruem: "Nothing ties the enumeration to the struct: adding a fourth option and populating it in the literal below, while forgetting the || clause, means the option is silently ignored on any deployment that sets it alone. Permissions are then wrong with no error and no log line. This diff is itself evidence that the drift surface is exercised, since removing the experiment required editing the condition and the literal in lockstep."
RoleOptions is a comparable all-bool struct, so the guard can be derived:
roleOpts := rbac.RoleOptions{
NoOwnerWorkspaceExec: bool(options.DeploymentValues.DisableOwnerWorkspaceExec),
NoWorkspaceSharing: bool(options.DeploymentValues.DisableWorkspaceSharing),
NoChatSharing: bool(options.DeploymentValues.DisableChatSharing),
}
if roleOpts != (rbac.RoleOptions{}) {
rbac.ReloadBuiltinRoles(&roleOpts)
}Meruem checked that this preserves the guard's actual purpose: builtInRoles is an atomic.Pointer (roles.go:324), so the guard is not there for memory safety, only to avoid republishing the global from every coderd.New. Takumi independently notes that narrowing this trigger is a real improvement in a process hosting more than one coderd instance, where one instance's startup rewrites roles under another's live traffic. Deriving it keeps that benefit and makes a dropped field impossible.
🤖
| return | ||
| } | ||
|
|
||
| // Deviations from rbac.DefaultOrgMemberRoles require the |
There was a problem hiding this comment.
P3 [CRF-22] Nothing narrows which built-in roles may become org-wide defaults, and the members API reports only explicit roles, so an org where every member holds organization-admin looks like an org full of plain members. (Kurapika P3, Luffy P3, Nami Note)
Kurapika: "The validation that survives only rejects non-built-in names (enterprise/coderd/organizations.go:69-83, rbac.RoleByName). Every org-scoped built-in passes, including organization-admin, organization-user-admin, organization-auditor."
The write itself is fine, and both reviewers checked before concluding: dbauthz.UpdateOrganization diffs the sets and runs canAssignRoles, enterprise/audit/table.go:361 tracks the column, and site-scoped names cannot be smuggled in because RoleByName rejects an identifier whose organization ID does not match. The gap is the derived state. Kurapika: "convertOrganizationMembers builds Roles from organization_members.roles only (coderd/members.go:516-534). An operator or audit consumer enumerating GET /api/v2/organizations/{org}/members sees [] for a member who effectively holds org-admin. The web UI compensates (OrganizationMembersPage.tsx:82-90); API consumers get no such field." Nami adds that the members table itself shows only explicit roles, so "an org that revokes organization-workspace-access from its defaults looks identical to one that grants it".
Luffy frames the scope question the promotion left open: "The stated requirement is 'let a Gateway Accounts org drop its members to the floor.' That needs the ability to remove roles. It does not need to offer org-admin to everyone."
Before this PR that state required opting into the experiment; now one PATCH on any deployment licensed for FeatureMultipleOrganizations reaches it, so the gap becomes the default surface and ships with the promotion. Two candidate fixes, and they are not equivalent: return the org's implied default roles on the member payload as a separate field so the implied grant stays distinguishable from the explicit one, or restrict default_org_member_roles to an allow-list of non-administrative roles. Kurapika: the second "is the stronger vow and costs you the ability to express 'all members are org admins' through this column at all."
Held at P3, not raised. The upward case is that an operator auditing org admins through the canonical endpoint gets a false negative on an authorization-relevant state. Against it: two reviewers independently rated P3, the write is authorized and audited, and the per-member dialog does render the implied rows. The false negative in the API enumeration is the part worth fixing.
🤖
| }, | ||
| }; | ||
|
|
||
| export const DefaultRolesEnabled: Story = { |
There was a problem hiding this comment.
Nit [CRF-8] Story DefaultRolesEnabled is named after the prop this diff deleted. (Netero)
Netero: "With defaultRolesEnabled gone, the story's only remaining distinguishing arg is defaultRolesEntitled: true, and its sibling is already DefaultRolesNotEntitled (:122). Rename to DefaultRolesEntitled."
🤖
| // is off (default), both roles carry the workspace-ops elevation. When | ||
| // on, both roles carry only the floor and the elevation must be | ||
| // granted explicitly via organization-workspace-access. | ||
| // TestMemberWorkspaceElevation verifies that organization-member and |
There was a problem hiding this comment.
Nit [CRF-29] The test doc and two inline comments restate the assertions beside them, and the test name states the opposite of what the test proves. (Gon P2, orchestrator Nit)
Gon's three instances, all in the rewritten test:
:206doc: "'carry only the floor' and 'the workspace-ops elevation is never bundled in' are one claim in two clauses." The part worth keeping is thedefault_org_member_roleslink, because nothing in the body shows it.:231: "'floor must always be present' is whatrequire.True(t, hasResource(member, floorWitness), \"organization-member should include the floor\")asserts." The clause that earns its line is the contrast that makes the identifier a witness: "ResourceOrganizationMember is part of the floor, not the elevation.":246: the comment and the failure message say the same thing. That comment has a second problem and CRF-17 owns it: it promises something the assertion does not check.
And the name: "The test asserts organization-member and organization-service-account do not carry the elevation. Read cold, the name says they do. TestMemberExcludesWorkspaceElevation."
Gon rated these P2. I moved them to Nit and the reasoning is on the record: severity is set by consequence, and the consequence of a comment restating the require message beside it is reader time, not behavior. The keep-argument for P2 is real, that five of eight comments this diff touches are defective and after GA these comments are the only explanation of a permission model that just changed, so the comments that do carry a behavioral consequence are filed at their own severity instead: CRF-14 (the missing explanation), CRF-17 (the comment that claims what the assertion never checks), CRF-27 (the undefined vocabulary). Gon's aggregate point stands: one editing pass over the diff's comments, not five point fixes.
🤖
| // Chat access requires the agents-access role and is intentionally | ||
| // not granted in the floor. | ||
| floor := Permissions(map[string][]policy.Action{ | ||
| memberPerms := Permissions(map[string][]policy.Action{ |
There was a problem hiding this comment.
Note [CRF-10] The upgrade path for existing deployments is covered, contrary to what the deletion suggests. (Netero, Razor, Melody, Knov, Mafuuu, Hisoka, Pariston, Ryosuke)
Eight reviewers traced this independently, so it is worth stating once as settled. Netero: "Removing the gate means organization-member never carries the elevation, but migration 000516_org_default_member_roles.up.sql backfills every existing org with ARRAY['organization-workspace-access'], and coderd.go:659 calls rolestore.ReconcileSystemRoles after ReloadBuiltinRoles at coderd.go:384, so DB-stored system roles are rewritten at startup. Existing members keep workspace access."
The supporting facts, each checked by someone: the column is NOT NULL with no SQL default, so no insert path can silently omit it (Hisoka, Komugi); 000198_ensure_default_org runs before 000516, so a fresh install's default org is backfilled too (Hisoka); the only production InsertOrganization caller passes rbac.DefaultOrgMemberRoles() (Razor, Knov, Mafuuu); the union fires for service accounts because the array_cat sits outside the is_service_account CASE (Hisoka, Meruem, Knov); the single production writer of UpdateOrganizationParams carries the existing value forward, so no unrelated org update blanks the column (Melody); and organization-workspace-access is assignable by orgAdmin and orgUserAdmin, so a gateway org is operable without a site owner (Hisoka, Pariston).
No action. Recorded so the next round does not re-derive it.
🤖
| Roles: Roles{ | ||
| must(RoleByName(RoleMember())), | ||
| orgMemberRole(defOrg), | ||
| must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))), |
There was a problem hiding this comment.
Note [CRF-9] Floor-only member coverage survives the fixture updates. (Netero, Melody)
Netero: "Six member subjects gained ScopedRoleOrgWorkspaceAccess, but siteUserAdminOrgMember (:802) and the subject at :1306 still carry orgMemberRole alone, so the new default (member without the workspace elevation) is still exercised in the authz tables. No gap."
Melody checked the two survivors are correct rather than overlooked: "authz_internal_test.go:802 asserts template ACL behavior and makes no workspace claim, and :1306 carries RoleOwner, whose site permissions cover workspaces."
No action. This is the answer to "did the fixture edits weaken the assertions", and it is no.
🤖
| @@ -1184,7 +1173,7 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { | |||
|
|
|||
There was a problem hiding this comment.
Note [CRF-33] Rolling back to a pre-GA binary silently restores workspace access to gateway-account members. (Mafuuu)
Mafuuu: "An org with default_org_member_roles = [] relies entirely on OrgMemberPermissions not bundling the elevation. Roll the binary back to a build where MinimumImplicitMember exists and defaults off, and organization-member carries OrgWorkspaceAccessMemberPerms() again: every member of that org regains workspace create, exec, and ssh, with no configuration change and no log line."
This was equally true under the experiment and the PR does not make it worse, but GA means more orgs will be in the configuration where it bites. Runbook item, not a code change: "do not roll back past this release with emptied default roles", stated rather than discovered.
Takumi checked the adjacent rolling-upgrade window and closed it: from v2.35.x an old replica's own GetAuthorizationUserRoles already unions the column, and from v2.34.x or earlier the old replica is already broken by 000516 itself, because its organization queries are SELECT * into a fixed 9-column struct. This PR creates no new mixed-version window. Established from git show v2.34.7:coderd/database/queries/organizations.sql, not from a live two-version fleet.
🤖
<!-- Authored with Coder Agents on behalf of @Emyrk --> Finishes the experiment removal on top of #27472: - Deletes `DeviationRejectedWithoutExperiment` (it asserted the removed 403 gate and would fail), renames the surviving subtests, and drops the now-pointless `dv.Experiments` setup. - Adds a test witnessing that workspace permissions stay off `organization-member`/`organization-service-account` and live on `organization-workspace-access`. - Frontend: removes both `experiments.includes("minimum-implicit-member")` sites and the whole `defaultRolesEnabled` prop plumbing (pages, view, stories), required once codegen drops the value from the `Experiment` union. - `make gen`: apidoc, `typesGenerated.ts`, feature-stages/schemas docs. Verified on the pre-rebase base at authoring time; the full-tree re-verification after the rebase onto main lives in the follow-up cleanup commit. <sub>Coder Agents on behalf of @Emyrk.</sub>
- Regenerate apidoc, typesGenerated.ts, and API reference docs so the minimum-implicit-member experiment is removed from generated output. - Update license usercount tests/bench added on main: the RoleOptions MinimumImplicitMember toggle is gone now that minimal member is the only behavior. - Give rbac authz test subjects and the workspaceconnwatcher member subject the organization-workspace-access role, matching the GA default_org_member_roles, since organization-member alone is now floor-only. - Biome formatting fix in CustomRolesPageView.tsx.
Frontend (Default Roles surface): - Correct the Default Roles copy: changes apply to existing members as well as new ones, and the internal term 'floor' is gone from all user-facing strings (CRF-11, CRF-12). - Show the Default Roles section read-only to viewers without editSettings; only the Edit button is permission-gated. Also disables Edit until the org roles query resolves (CRF-24, CRF-25, CRF-26). - Roles a member holds explicitly stay selectable in the member role dialog even when implied by org defaults, so overlapping explicit grants can be removed (CRF-13). - Rename stories named after the deleted prop (CRF-8). RBAC: - Restore prose explaining that organization-member grants no workspace access and define the floor/workspace-access split where it lives (CRF-14, CRF-27, CRF-5). - Unexport OrgWorkspaceAccessMemberPerms (CRF-18). - Rework the member-roles test to assert against the registered organization-workspace-access role instead of a witness derived from itself; use slices.ContainsFunc; drop the no-op reload pair (CRF-17, CRF-29, CRF-30, CRF-32, CRF-6). - coderd.go builds the full RoleOptions struct and compares to the zero value, so new fields cannot be silently ignored (CRF-20). Tests: - Remove the no-op ReloadBuiltinRoles(nil) pairs from the license usercount tests and parallelize them; rename the MinimumMember subtest; drop the orphaned bench comment (CRF-1, CRF-7, CRF-4). Docs: - Add a 'Default member roles' section to the organizations admin page and cross-link it from groups-roles, adapted for GA from the closed docs PR #26702 (CRF-3).
406bb94 to
65cef72
Compare
|
Deep-review R1 disposition (fixes in Fixed
Acknowledged, not in this PR
Verified: Generated by Coder Agents on behalf of @Emyrk. |
Promotes the
minimum-implicit-memberexperiment to GA and removes it.What changes
minimum-implicit-memberexperiment constant, itsRoleOptions.MinimumImplicitMembertoggle, and the globalrbac.MinimumImplicitMember()accessor are deleted. The minimal-member behavior is now the only behavior:organization-memberandorganization-service-accountcarry only the floor (read-self records, notifications, and similar) and grant no workspace permissions. Workspace access lives exclusively on theorganization-workspace-accessrole.default_org_member_roles(PATCH /organizations/{org}) is removed; the built-in-roles-only validation remains.docs/admin/users/organizations.md, cross-linked fromgroups-roles.md.Why this is safe for existing deployments
Migration
000516(shipped earlier) backfilleddefault_org_member_roleswith['organization-workspace-access']on every organization. Members therefore keep exactly the effective permissions they had with the experiment off; the workspace elevation flows through the default role instead of being baked intoorganization-member.Rollback caveat: rolling back past this release restores the bundled elevation, silently re-granting workspace access to members of organizations that cleared their default roles.
Review
Deep-review R1 findings are addressed in
chore: address deep-review findings(copy fixes, read-only Default Roles for viewers, removable overlapping explicit grants, RBAC prose restoration, test de-tautologizing, docs). Point-by-point disposition is in the PR comments.Generated by Coder Agents on behalf of @Emyrk.