feat: add organizations.default_org_member_roles for Gateway Accounts#25994
feat: add organizations.default_org_member_roles for Gateway Accounts#25994Emyrk wants to merge 1 commit into
Conversation
Docs preview📖 View docs preview for |
| 'on the next request. Used by the minimum-implicit-member experiment ' | ||
| 'to control whether workspace-operations capabilities are bundled into ' | ||
| 'the organization-member floor or attached via organization-workspace-access.'; |
There was a problem hiding this comment.
Do not mention the experiment because this column will exist after the experiment goes live. Just mention that some deployments may want revoke what is considered "normal permissions"
There was a problem hiding this comment.
Done. Rewrote the comment to drop the experiment reference and focus on the durable purpose: deployments can use this column to revoke capabilities that would otherwise be considered normal organization member permissions.
55edab1 to
1451f52
Compare
|
Design change: dropped the SQL
Rationale: the SQL Coder Agents on behalf of @Emyrk. |
| ALTER TABLE organizations | ||
| ADD COLUMN default_org_member_roles text[]; | ||
|
|
||
| UPDATE organizations | ||
| SET default_org_member_roles = ARRAY['organization-workspace-access']::text[]; | ||
|
|
||
| ALTER TABLE organizations | ||
| ALTER COLUMN default_org_member_roles SET NOT NULL; | ||
|
|
||
| COMMENT ON COLUMN organizations.default_org_member_roles IS | ||
| 'Roles granted to every member of this organization at request time. ' | ||
| 'The set is unioned into each member''s effective roles when ' | ||
| 'GetAuthorizationUserRoles runs, so changes propagate to all members ' | ||
| 'on the next request. Deployments can use this column to revoke ' | ||
| 'capabilities that would otherwise be considered normal organization ' | ||
| 'member permissions.'; |
There was a problem hiding this comment.
Existing orgs are backfilled this way
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.6.1 | Round 1 | Last posted: Round 1, 12 findings (1 P1, 4 P3, 4 Nit, 3 Note), REQUEST_CHANGES. Review Finding inventoryFinding InventoryFindings
Round logRound 1Panel (12 reviewers: Bisky, Hisoka, Mafuuu, Pariston, Gon, Leorio, Knuckle, Kurapika, Meruem, Killua, Knov, Razor). Netero: 0 findings. 1 P1, 4 P3, 4 Nit, 3 Note. Reviewed against 6aef7c0..1451f52. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Well-structured preparation PR. The resolve-at-read approach via GetAuthorizationUserRoles is a clean design: one SQL union point, no backfill needed, atomic propagation on next request. Test density is strong at 60%, and the three enterprise subtests cover the experiment gate thoroughly.
1 P1, 4 P3, 4 Nit, 3 Note.
The critical gap is the absence of any validation on role names written to default_org_member_roles. Nine of twelve reviewers converged on this independently. The P1 finding (CRF-1) traces two concrete attack paths: privilege escalation through built-in role injection, and DoS through malformed names that break RoleNameFromString and produce 500s for every org member. The experiment gate limits blast radius today but provides no structural protection.
Additional observations:
- PR description inaccuracy (CRF-11): The implementation notes state the migration adds a column with
DEFAULT ARRAY['organization-workspace-access']::text[]. The actual migration adds the column without a DEFAULT, backfills existing rows, then sets NOT NULL. The Go code supplies the value at every insertion point. Consider correcting the description to avoid misleading future readers. - PR title scope: The scope
coderddoes not contain all changed files (changes spancoderd/,codersdk/,enterprise/,site/). Per project rules, either use a broader path scope or omit the scope:feat: add organizations.default_org_member_roles for Gateway Accounts.
"The assumption 'callers will only shrink, not escalate' is invisible." (Meruem)
coderd/rbac/roles.go:1186
Note [CRF-12] Shrinking default_org_member_roles to empty is currently a no-op. organization-member (line 1181) and organization-service-account (line 1258) still bundle OrgWorkspaceAccessMemberPerms(). An operator who enables the experiment and sets default_org_member_roles = [] gets a successful API response, but effective permissions don't change because the member role still grants the same workspace-ops set. The PR acknowledges this as staged delivery. Noting it here so the follow-up is not forgotten. (Knov)
🤖
🤖 This review was automatically generated with Coder Agents.
| if req.Icon != nil { | ||
| updateOrgParams.Icon = *req.Icon | ||
| } | ||
| if req.DefaultOrgMemberRoles != nil { |
There was a problem hiding this comment.
P1 [CRF-1] No validation that entries in DefaultOrgMemberRoles are legitimate role names. Two traced consequences:
-
Privilege escalation. With the experiment enabled,
PATCH /organizations/{id}with{"default_org_member_roles": ["organization-admin"]}stores the value.GetAuthorizationUserRolesproducesorganization-admin:<org_id>for every member.rolestore.Expandresolves it againstbuiltInRoles(roles.go:32, 543). Every member gets org-admin permissions. Individual role revocation is defeated: a demoted admin retains privileges through this column. -
DoS. A role name containing a colon (e.g.,
"foo:bar") producesfoo:bar:<org_id>after SQL concatenation.RoleNameFromString(roles.go:79) rejects it with "too many colons." The error propagates throughUserRBACSubjectto the apikey middleware (apikey.go:474), which returns a hard 500 for every authenticated request from every member of the org. Recovery requires direct database access.
The experiment gate limits blast radius today, but provides no structural protection against misuse. Compare with putMemberRoles, where canAssignRoles validates every role name against the built-in registry and checks ActionAssign on ResourceAssignOrgRole.
Fix: validate each entry against rbac.RoleByName(RoleIdentifier{Name: role, OrganizationID: org.ID}) before writing. Consider constraining to a subset of rbac.DefaultOrgMemberRoles since the stated purpose is shrinking the floor, not granting arbitrary roles. (Hisoka P1, Kurapika P1, Mafuuu P2, Meruem P2, Knov P2, Knuckle P2, Razor P2, Pariston P3, Bisky P3)
🤖
There was a problem hiding this comment.
This is a good callout. We need to check if the role can be assigned by the caller
There was a problem hiding this comment.
Done. Two-layer fix in 4d52fbe8b8:
API layer (enterprise/coderd/organizations.go): added a built-in-only validation after the experiment gate. Each entry in req.DefaultOrgMemberRoles must resolve via rbac.RoleByName; anything else (custom role, malformed name with extra colons) returns 400 Invalid default_org_member_roles entry. This blocks the DoS attack path directly: "foo:bar" never reaches the SQL. Callout comment notes the temporary restriction so a future change can extend to custom org roles. Test in enterprise/coderd/organizations_test.go::TestPatchOrganizationsByUser/DefaultOrgMemberRoles/NonBuiltInRoleRejected.
dbauthz layer (coderd/database/dbauthz/dbauthz.go): rewrote UpdateOrganization to (1) authorize ActionUpdate on the org, then (2) when DefaultOrgMemberRoles differs from the existing value, compute the added/removed diff (as rbac.RoleIdentifiers scoped to the org) and route through the same canAssignRoles helper used by InsertOrganizationMember and UpdateMemberRoles. So the caller must hold ActionAssign on ResourceAssignOrgRole.InOrg(orgID) and must individually pass rbac.CanAssignRole for each added or removed role. This closes the escalation path: even with the experiment on, a non-admin caller can't slip organization-admin into the default set unless they were already permitted to assign org-admin individually. Test in coderd/database/dbauthz/dbauthz_test.go::UpdateOrganization now asserts both ActionUpdate and ActionAssign are required.
There was a problem hiding this comment.
Replaced the hand-rolled defaultOrgMemberRolesDiff with rbac.ChangeRoleSet per Steven's review. The dbauthz UpdateOrganization path now wraps the stored []string as RoleIdentifiers via a tiny scopedOrgRoleIdentifiers helper and passes them straight through rbac.ChangeRoleSet -> canAssignRoles. Head: e6dfeb2988.
| // DefaultOrgMemberRoles is the deployment-wide default for the | ||
| // organizations.default_org_member_roles column, applied to every | ||
| // new organization at creation time. | ||
| var DefaultOrgMemberRoles = []string{orgWorkspaceAccess} |
There was a problem hiding this comment.
P3 [CRF-2] DefaultOrgMemberRoles is an exported mutable var slice used in security-relevant comparisons.
var DefaultOrgMemberRoles = []string{orgWorkspaceAccess}The experiment gate in patchOrganization (organizations.go:70) compares the request against this value via slices.Equal. dbgen.Organization uses it as the creation default. Any code path that mutates the backing array (append, index assignment, variable reassignment) silently corrupts both the gate comparison and the creation default for all subsequent requests.
No current code mutates it, but Go exported var slices are a known mutation hazard. Seven of twelve reviewers flagged this independently.
Fix: return a copy from a function:
func DefaultOrgMemberRoles() []string { return []string{orgWorkspaceAccess} }All six call sites change from rbac.DefaultOrgMemberRoles to rbac.DefaultOrgMemberRoles(). (Hisoka, Kurapika, Meruem, Knov, Razor, Gon, Mafuuu)
🤖
There was a problem hiding this comment.
Done in 4d52fbe8b8. Converted to a function returning a fresh slice on each call. Updated all 12 call sites (coderd/rbac/roles.go, coderd/database/dbgen/dbgen.go, enterprise/coderd/organizations.go, coderd/provisionerdserver/acquirer_test.go, coderd/httpmw/authorize_test.go x2, coderd/httpmw/organizationparam_test.go, cli/server_createadminuser_test.go x2, enterprise/coderd/organizations_test.go x2). Doc comment now also states the "sole authoritative source / every InsertOrganization site must supply this value" invariant per CRF-4.
| // DefaultOrgMemberRoles is the deployment-wide default for the | ||
| // organizations.default_org_member_roles column, applied to every | ||
| // new organization at creation time. |
There was a problem hiding this comment.
P3 [CRF-4] Comment omits the sync invariant and authoritative-source documentation.
The PR description says the comment documents a sync requirement with the migration's column DEFAULT, but (a) no such sync requirement is stated in the comment, and (b) the column has no DEFAULT. The Go constant is the sole authoritative source of the default value. Every InsertOrganization call site must use it. A reader adding a new insert path or raw SQL script has no warning.
Suggested rewrite:
// DefaultOrgMemberRoles is the authoritative default for
// organizations.default_org_member_roles. The column has no DB-level
// DEFAULT, so every InsertOrganization call site must supply this value.(Gon, Leorio)
🤖
There was a problem hiding this comment.
Folded into the CRF-2 change in 4d52fbe8b8. The new DefaultOrgMemberRoles doc comment reads:
// DefaultOrgMemberRoles is the deployment-wide default for the
// organizations.default_org_member_roles column, applied to every new
// organization at creation time. The column has no SQL DEFAULT, so this
// is the sole authoritative source: every InsertOrganization call site
// must supply this value unless a caller-chosen override is required.
// Returned as a fresh slice each call to prevent accidental mutation of
// the shared default through append or index assignment.
| // Writes to default_org_member_roles that deviate from the | ||
| // deployment-wide default require the minimum-implicit-member | ||
| // experiment. Without the experiment, every organization keeps the | ||
| // standard floor + organization-workspace-access elevation. |
There was a problem hiding this comment.
Nit [CRF-8] Guard comment restates the if-condition. The code is self-documenting: the three-line conditional with slices.Equal, rbac.DefaultOrgMemberRoles, and Experiments.Enabled(...) communicates intent clearly. The second sentence ("standard floor + organization-workspace-access elevation") references architectural terms that don't appear locally.
Suggested trim:
// Deviations from rbac.DefaultOrgMemberRoles require the
// minimum-implicit-member experiment.(Gon)
🤖
There was a problem hiding this comment.
Done in 4d52fbe8b8. Trimmed to your suggested:
// Deviations from rbac.DefaultOrgMemberRoles require the
// minimum-implicit-member experiment.
| // DefaultOrgMemberRoles, when non-nil, replaces the org's default | ||
| // member roles. |
There was a problem hiding this comment.
Nit [CRF-9] The doc comment // DefaultOrgMemberRoles, when non-nil, replaces the org's default member roles. produces confusing generated API docs. Codegen expands the CamelCase name, creating: "Default org member roles when non-nil, replaces the org's default member roles." The field name merges into the sentence, making "when non-nil" read like a conditional on the roles themselves.
Fix: // When non-nil, replaces the org's default member roles. (Leorio)
🤖
There was a problem hiding this comment.
Keeping as-is for now. The codegen-friendly rewording is a worthwhile fix but is incidental to this PR; will land alongside the next codersdk-touching change.
| 'organization-member' | ||
| END | ||
| ), | ||
| organizations.default_org_member_roles |
There was a problem hiding this comment.
Note [CRF-10] default_org_member_roles applies uniformly to all org members, including service accounts. When an admin shrinks an org's defaults for Gateway Accounts (e.g., removes organization-workspace-access), service accounts in that org also lose those roles. This aligns with the PR description's "per-org, not per-user" intent, but is worth confirming explicitly in the follow-up that gateway-account orgs won't have service accounts that need workspace access. (Pariston, Meruem, Razor)
🤖
There was a problem hiding this comment.
Confirmed. By design: default_org_member_roles is per-org and unioned for both organization-member and organization-service-account. Shrinking the column affects every member of the org including service accounts. TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles now exercises both code paths.
| @@ -0,0 +1,16 @@ | |||
| ALTER TABLE organizations | |||
There was a problem hiding this comment.
Note [CRF-11] The PR description states the migration adds a column with DEFAULT ARRAY['organization-workspace-access']::text[]. The actual migration adds the column without a DEFAULT, backfills existing rows, then sets NOT NULL. The Go code (rbac.DefaultOrgMemberRoles) is the sole authoritative source, supplied at every InsertOrganization call site. The NOT NULL constraint provides fail-closed behavior (raw SQL inserts that omit the column will fail). Consider correcting the PR description to avoid misleading future readers about the migration's behavior. (Leorio, Hisoka, Mafuuu, Meruem, Pariston, Razor)
🤖
There was a problem hiding this comment.
PR description updated to reflect the actual nullable -> backfill -> NOT NULL pattern and the rbac.DefaultOrgMemberRoles() function as the sole authoritative source.
1451f52 to
7511db4
Compare
7511db4 to
3efb4a4
Compare
|
Per follow-up review on the audit: rolled the
Full test sweep: Coder Agents on behalf of @Emyrk. |
3efb4a4 to
c5afc1c
Compare
|
Follow-up amend ( Why: Change in
Test: Coder Agents on behalf of @Emyrk. |
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
c5afc1c to
4d52fbe
Compare
|
Bot review response (
Coder Agents on behalf of @Emyrk. |
286774c to
e6dfeb2
Compare
fb33230 to
eafa7f5
Compare
e6dfeb2 to
85045f1
Compare

Refs #25936. Stacks on #25929.
Adds
organizations.default_org_member_rolesand unions it into every member's effective roles at read time. Changes to the column propagate to all members on the next request. Deployment-wide default is{'organization-workspace-access'}so this PR is behavior-preserving.minimum-implicit-memberexperiment is on (Gateway Accounts are not enabled on this deployment.).rbac.RoleByNameis rejected with HTTP 400 to block both custom roles and malformed names that would otherwise breakRoleNameFromStringdownstream.UpdateOrganizationroutes changes todefault_org_member_rolesthroughcanAssignRoles, so the caller must be permitted to assign each added or removed role, mirroringInsertOrganizationMemberandUpdateMemberRoles.GetAuthorizationUserRoles(single SQL change, atomic snapshot per request).Implementation notes
ARRAY['organization-workspace-access'], then setsNOT NULL. The column has no SQLDEFAULT;rbac.DefaultOrgMemberRoles()is the sole authoritative source and everyInsertOrganizationcall site must supply this value. TheNOT NULLconstraint provides fail-closed behavior for any raw SQL that omits the column.GetAuthorizationUserRolesjoinsorganizationsandarray_cats the column into the unnest, so the SQL is the only union point. The union applies to regular users and to service accounts.rbac.DefaultOrgMemberRoles()is a function (not avar) so callers cannot mutate the shared deployment default.UpdateOrganizationParams(sqlc-gen) preserves the existing value when the request omits the field.Coder Agents on behalf of @Emyrk.