diff --git a/cli/server_createadminuser_test.go b/cli/server_createadminuser_test.go
index a8f34250fe5..a0cc4c2f662 100644
--- a/cli/server_createadminuser_test.go
+++ b/cli/server_createadminuser_test.go
@@ -106,17 +106,19 @@ func TestServerCreateAdminUser(t *testing.T) {
org1Name, org1ID := "org1", uuid.New()
org2Name, org2ID := "org2", uuid.New()
_, err = db.InsertOrganization(ctx, database.InsertOrganizationParams{
- ID: org1ID,
- Name: org1Name,
- CreatedAt: dbtime.Now(),
- UpdatedAt: dbtime.Now(),
+ ID: org1ID,
+ Name: org1Name,
+ CreatedAt: dbtime.Now(),
+ UpdatedAt: dbtime.Now(),
+ DefaultOrgMemberRoles: rbac.DefaultOrgMemberRoles(),
})
require.NoError(t, err)
_, err = db.InsertOrganization(ctx, database.InsertOrganizationParams{
- ID: org2ID,
- Name: org2Name,
- CreatedAt: dbtime.Now(),
- UpdatedAt: dbtime.Now(),
+ ID: org2ID,
+ Name: org2Name,
+ CreatedAt: dbtime.Now(),
+ UpdatedAt: dbtime.Now(),
+ DefaultOrgMemberRoles: rbac.DefaultOrgMemberRoles(),
})
require.NoError(t, err)
diff --git a/cli/testdata/coder_organizations_list_--help.golden b/cli/testdata/coder_organizations_list_--help.golden
index 81978864113..188a129e578 100644
--- a/cli/testdata/coder_organizations_list_--help.golden
+++ b/cli/testdata/coder_organizations_list_--help.golden
@@ -11,7 +11,7 @@ USAGE:
read.
OPTIONS:
- -c, --column [id|name|display name|icon|description|created at|updated at|default] (default: name,display name,id,default)
+ -c, --column [id|name|display name|icon|description|created at|updated at|default|default org member roles] (default: name,display name,id,default)
Columns to display in table output.
-o, --output table|json (default: table)
diff --git a/cli/testdata/coder_organizations_show_--help.golden b/cli/testdata/coder_organizations_show_--help.golden
index 479182ac75e..c3e0bab898e 100644
--- a/cli/testdata/coder_organizations_show_--help.golden
+++ b/cli/testdata/coder_organizations_show_--help.golden
@@ -25,7 +25,7 @@ USAGE:
$ Show organization with the given ID.
OPTIONS:
- -c, --column [id|name|display name|icon|description|created at|updated at|default] (default: id,name,default)
+ -c, --column [id|name|display name|icon|description|created at|updated at|default|default org member roles] (default: id,name,default)
Columns to display in table output.
--only-id bool
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 56aaa2a95db..d43886d80a1 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -19252,12 +19252,14 @@ const docTemplate = `{
"oauth2",
"mcp-server-http",
"workspace-build-updates",
- "nats_pubsub"
+ "nats_pubsub",
+ "minimum-implicit-member"
],
"x-enum-comments": {
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentExample": "This isn't used for anything.",
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
+ "ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.",
"ExperimentNATSPubsub": "Enables embedded NATS pubsub.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentOAuth2": "Enables OAuth2 provider functionality.",
@@ -19272,7 +19274,8 @@ const docTemplate = `{
"Enables OAuth2 provider functionality.",
"Enables the MCP HTTP server functionality.",
"Enables publishing workspace build updates to the all builds pubsub channel.",
- "Enables embedded NATS pubsub."
+ "Enables embedded NATS pubsub.",
+ "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts."
],
"x-enum-varnames": [
"ExperimentExample",
@@ -19282,7 +19285,8 @@ const docTemplate = `{
"ExperimentOAuth2",
"ExperimentMCPServerHTTP",
"ExperimentWorkspaceBuildUpdates",
- "ExperimentNATSPubsub"
+ "ExperimentNATSPubsub",
+ "ExperimentMinimumImplicitMember"
]
},
"codersdk.ExternalAPIKeyScopes": {
@@ -21063,6 +21067,13 @@ const docTemplate = `{
"type": "string",
"format": "date-time"
},
+ "default_org_member_roles": {
+ "description": "DefaultOrgMemberRoles are unioned into every member's effective\nroles at request time. Changes propagate to all members on the\nnext request.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string"
},
@@ -24518,6 +24529,13 @@ const docTemplate = `{
"codersdk.UpdateOrganizationRequest": {
"type": "object",
"properties": {
+ "default_org_member_roles": {
+ "description": "DefaultOrgMemberRoles, when non-nil, replaces the org's default\nmember roles.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string"
},
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 606f7ae4ea4..e6cd64b7a1f 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -17490,12 +17490,14 @@
"oauth2",
"mcp-server-http",
"workspace-build-updates",
- "nats_pubsub"
+ "nats_pubsub",
+ "minimum-implicit-member"
],
"x-enum-comments": {
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentExample": "This isn't used for anything.",
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
+ "ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.",
"ExperimentNATSPubsub": "Enables embedded NATS pubsub.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentOAuth2": "Enables OAuth2 provider functionality.",
@@ -17510,7 +17512,8 @@
"Enables OAuth2 provider functionality.",
"Enables the MCP HTTP server functionality.",
"Enables publishing workspace build updates to the all builds pubsub channel.",
- "Enables embedded NATS pubsub."
+ "Enables embedded NATS pubsub.",
+ "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts."
],
"x-enum-varnames": [
"ExperimentExample",
@@ -17520,7 +17523,8 @@
"ExperimentOAuth2",
"ExperimentMCPServerHTTP",
"ExperimentWorkspaceBuildUpdates",
- "ExperimentNATSPubsub"
+ "ExperimentNATSPubsub",
+ "ExperimentMinimumImplicitMember"
]
},
"codersdk.ExternalAPIKeyScopes": {
@@ -19226,6 +19230,13 @@
"type": "string",
"format": "date-time"
},
+ "default_org_member_roles": {
+ "description": "DefaultOrgMemberRoles are unioned into every member's effective\nroles at request time. Changes propagate to all members on the\nnext request.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string"
},
@@ -22541,6 +22552,13 @@
"codersdk.UpdateOrganizationRequest": {
"type": "object",
"properties": {
+ "default_org_member_roles": {
+ "description": "DefaultOrgMemberRoles, when non-nil, replaces the org's default\nmember roles.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"description": {
"type": "string"
},
diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go
index bc93df7cd31..984fdc6b098 100644
--- a/coderd/database/db2sdk/db2sdk.go
+++ b/coderd/database/db2sdk/db2sdk.go
@@ -902,10 +902,11 @@ func Organization(organization database.Organization) codersdk.Organization {
DisplayName: organization.DisplayName,
Icon: organization.Icon,
},
- Description: organization.Description,
- CreatedAt: organization.CreatedAt,
- UpdatedAt: organization.UpdatedAt,
- IsDefault: organization.IsDefault,
+ Description: organization.Description,
+ CreatedAt: organization.CreatedAt,
+ UpdatedAt: organization.UpdatedAt,
+ IsDefault: organization.IsDefault,
+ DefaultOrgMemberRoles: organization.DefaultOrgMemberRoles,
}
}
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index afa14877478..4b08644dec7 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -1602,6 +1602,19 @@ func (q *querier) authorizeProvisionerJob(ctx context.Context, job database.Prov
return nil
}
+// scopedOrgRoleIdentifiers wraps each role name as a RoleIdentifier scoped
+// to orgID. Used to feed rbac.ChangeRoleSet from a stored []string.
+func scopedOrgRoleIdentifiers(names []string, orgID uuid.UUID) []rbac.RoleIdentifier {
+ if len(names) == 0 {
+ return nil
+ }
+ out := make([]rbac.RoleIdentifier, len(names))
+ for i, name := range names {
+ out[i] = rbac.RoleIdentifier{Name: name, OrganizationID: orgID}
+ }
+ return out
+}
+
func (q *querier) AcquireChats(ctx context.Context, arg database.AcquireChatsParams) ([]database.Chat, error) {
// AcquireChats is a system-level operation used by the chat processor.
// Authorization is done at the system level, not per-user.
@@ -5779,9 +5792,23 @@ func (q *querier) InsertOrganizationMember(ctx context.Context, arg database.Ins
return database.OrganizationMember{}, xerrors.Errorf("converting to organization roles: %w", err)
}
+ // The org's default_org_member_roles are implied at request time by
+ // GetAuthorizationUserRoles. Include them in canAssignRoles so the
+ // caller is required to be authorized to grant the full effective set
+ // (the explicit roles, organization-member, plus the defaults).
+ org, err := q.db.GetOrganizationByID(ctx, arg.OrganizationID)
+ if err != nil {
+ return database.OrganizationMember{}, xerrors.Errorf("get organization: %w", err)
+ }
+ defaultRoles, err := q.convertToOrganizationRoles(arg.OrganizationID, org.DefaultOrgMemberRoles)
+ if err != nil {
+ return database.OrganizationMember{}, xerrors.Errorf("convert default member roles: %w", err)
+ }
+
// All roles are added roles. Org member is always implied.
//nolint:gocritic
addedRoles := append(orgRoles, rbac.ScopedRoleOrgMember(arg.OrganizationID))
+ addedRoles = append(addedRoles, defaultRoles...)
err = q.canAssignRoles(ctx, arg.OrganizationID, addedRoles, []rbac.RoleIdentifier{})
if err != nil {
return database.OrganizationMember{}, err
@@ -7049,9 +7076,23 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb
return database.OrganizationMember{}, err
}
+ // The org's default_org_member_roles are implied at request time by
+ // GetAuthorizationUserRoles. Include them in the implied set so
+ // canAssignRoles validates the caller can grant the full effective set
+ // (the granted roles, organization-member, plus the defaults).
+ org, err := q.db.GetOrganizationByID(ctx, arg.OrgID)
+ if err != nil {
+ return database.OrganizationMember{}, xerrors.Errorf("get organization: %w", err)
+ }
+ defaultRoles, err := q.convertToOrganizationRoles(arg.OrgID, org.DefaultOrgMemberRoles)
+ if err != nil {
+ return database.OrganizationMember{}, xerrors.Errorf("convert default member roles: %w", err)
+ }
+
// The org member role is always implied.
//nolint:gocritic
impliedTypes := append(scopedGranted, rbac.ScopedRoleOrgMember(arg.OrgID))
+ impliedTypes = append(impliedTypes, defaultRoles...)
added, removed := rbac.ChangeRoleSet(originalRoles, impliedTypes)
err = q.canAssignRoles(ctx, arg.OrgID, added, removed)
@@ -7092,10 +7133,29 @@ func (q *querier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.
}
func (q *querier) UpdateOrganization(ctx context.Context, arg database.UpdateOrganizationParams) (database.Organization, error) {
- fetch := func(ctx context.Context, arg database.UpdateOrganizationParams) (database.Organization, error) {
- return q.db.GetOrganizationByID(ctx, arg.ID)
+ existing, err := q.db.GetOrganizationByID(ctx, arg.ID)
+ if err != nil {
+ return database.Organization{}, err
+ }
+ if err := q.authorizeContext(ctx, policy.ActionUpdate, existing); err != nil {
+ return database.Organization{}, err
+ }
+ // Treat a change to default_org_member_roles as assigning the added
+ // roles, and unassigning the removed roles, for every member of the
+ // org. Mirror the InsertOrganizationMember and UpdateMemberRoles
+ // guard so the caller cannot grant roles they could not grant
+ // individually, nor inject a malformed role name that would later
+ // break RoleNameFromString.
+ if !slices.Equal(existing.DefaultOrgMemberRoles, arg.DefaultOrgMemberRoles) {
+ added, removed := rbac.ChangeRoleSet(
+ scopedOrgRoleIdentifiers(existing.DefaultOrgMemberRoles, arg.ID),
+ scopedOrgRoleIdentifiers(arg.DefaultOrgMemberRoles, arg.ID),
+ )
+ if err := q.canAssignRoles(ctx, arg.ID, added, removed); err != nil {
+ return database.Organization{}, err
+ }
}
- return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateOrganization)(ctx, arg)
+ return q.db.UpdateOrganization(ctx, arg)
}
func (q *querier) UpdateOrganizationDeletedByID(ctx context.Context, arg database.UpdateOrganizationDeletedByIDParams) error {
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index 895f75b4625..916eca23198 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -2266,9 +2266,10 @@ func (s *MethodTestSuite) TestOrganization() {
check.Args(arg).Asserts(org, policy.ActionUpdate).Returns(org)
}))
s.Run("InsertOrganizationMember", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
- o := testutil.Fake(s.T(), faker, database.Organization{})
+ o := testutil.Fake(s.T(), faker, database.Organization{DefaultOrgMemberRoles: []string{}})
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.InsertOrganizationMemberParams{OrganizationID: o.ID, UserID: u.ID, Roles: []string{codersdk.RoleOrganizationAdmin}}
+ dbm.EXPECT().GetOrganizationByID(gomock.Any(), o.ID).Return(o, nil).AnyTimes()
dbm.EXPECT().InsertOrganizationMember(gomock.Any(), arg).Return(database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID, Roles: arg.Roles}, nil).AnyTimes()
check.Args(arg).Asserts(
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign,
@@ -2305,12 +2306,17 @@ func (s *MethodTestSuite) TestOrganization() {
).WithNotAuthorized("no rows").WithCancelled(sql.ErrNoRows.Error())
}))
s.Run("UpdateOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
- o := testutil.Fake(s.T(), faker, database.Organization{Name: "something-unique"})
- arg := database.UpdateOrganizationParams{ID: o.ID, Name: "something-different"}
+ o := testutil.Fake(s.T(), faker, database.Organization{Name: "something-unique", DefaultOrgMemberRoles: []string{}})
+ // Change DefaultOrgMemberRoles so canAssignRoles fires alongside the
+ // ActionUpdate check; mirrors the InsertOrganizationMember pattern.
+ arg := database.UpdateOrganizationParams{ID: o.ID, Name: "something-different", DefaultOrgMemberRoles: []string{codersdk.RoleOrganizationAdmin}}
dbm.EXPECT().GetOrganizationByID(gomock.Any(), o.ID).Return(o, nil).AnyTimes()
dbm.EXPECT().UpdateOrganization(gomock.Any(), arg).Return(o, nil).AnyTimes()
- check.Args(arg).Asserts(o, policy.ActionUpdate)
+ check.Args(arg).Asserts(
+ o, policy.ActionUpdate,
+ rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign,
+ )
}))
s.Run("UpdateOrganizationDeletedByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
o := testutil.Fake(s.T(), faker, database.Organization{Name: "doomed"})
@@ -2347,13 +2353,14 @@ func (s *MethodTestSuite) TestOrganization() {
check.Args(arg).Asserts(rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead).Returns(rows)
}))
s.Run("UpdateMemberRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
- o := testutil.Fake(s.T(), faker, database.Organization{})
+ o := testutil.Fake(s.T(), faker, database.Organization{DefaultOrgMemberRoles: []string{}})
u := testutil.Fake(s.T(), faker, database.User{})
mem := testutil.Fake(s.T(), faker, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID, Roles: []string{codersdk.RoleOrganizationAdmin}})
out := mem
out.Roles = []string{}
dbm.EXPECT().OrganizationMembers(gomock.Any(), database.OrganizationMembersParams{OrganizationID: o.ID, UserID: u.ID, IncludeSystem: false}).Return([]database.OrganizationMembersRow{{OrganizationMember: mem}}, nil).AnyTimes()
+ dbm.EXPECT().GetOrganizationByID(gomock.Any(), o.ID).Return(o, nil).AnyTimes()
arg := database.UpdateMemberRolesParams{GrantedRoles: []string{}, UserID: u.ID, OrgID: o.ID}
dbm.EXPECT().UpdateMemberRoles(gomock.Any(), arg).Return(out, nil).AnyTimes()
diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go
index 7c2bb68a007..9d9e12f1187 100644
--- a/coderd/database/dbgen/dbgen.go
+++ b/coderd/database/dbgen/dbgen.go
@@ -1034,13 +1034,14 @@ func GitSSHKey(t testing.TB, db database.Store, orig database.GitSSHKey) databas
func Organization(t testing.TB, db database.Store, orig database.Organization) database.Organization {
org, err := db.InsertOrganization(genCtx, database.InsertOrganizationParams{
- ID: takeFirst(orig.ID, uuid.New()),
- Name: takeFirst(orig.Name, testutil.GetRandomName(t)),
- DisplayName: takeFirst(orig.Name, testutil.GetRandomName(t)),
- Description: takeFirst(orig.Description, testutil.GetRandomName(t)),
- Icon: takeFirst(orig.Icon, ""),
- CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
- UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()),
+ ID: takeFirst(orig.ID, uuid.New()),
+ Name: takeFirst(orig.Name, testutil.GetRandomName(t)),
+ DisplayName: takeFirst(orig.Name, testutil.GetRandomName(t)),
+ Description: takeFirst(orig.Description, testutil.GetRandomName(t)),
+ Icon: takeFirst(orig.Icon, ""),
+ CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
+ UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()),
+ DefaultOrgMemberRoles: takeFirstSlice(orig.DefaultOrgMemberRoles, rbac.DefaultOrgMemberRoles()),
})
require.NoError(t, err, "insert organization")
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index ea09f4300fd..9d2b8e3fc56 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -2375,11 +2375,14 @@ CREATE TABLE organizations (
display_name text NOT NULL,
icon text DEFAULT ''::text NOT NULL,
deleted boolean DEFAULT false NOT NULL,
- shareable_workspace_owners shareable_workspace_owners DEFAULT 'everyone'::shareable_workspace_owners NOT NULL
+ shareable_workspace_owners shareable_workspace_owners DEFAULT 'everyone'::shareable_workspace_owners NOT NULL,
+ default_org_member_roles text[] NOT NULL
);
COMMENT ON COLUMN organizations.shareable_workspace_owners IS 'Controls whose workspaces can be shared: none, everyone, or service_accounts.';
+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.';
+
CREATE TABLE parameter_schemas (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
diff --git a/coderd/database/migrations/000516_org_default_member_roles.down.sql b/coderd/database/migrations/000516_org_default_member_roles.down.sql
new file mode 100644
index 00000000000..f56201df50e
--- /dev/null
+++ b/coderd/database/migrations/000516_org_default_member_roles.down.sql
@@ -0,0 +1 @@
+ALTER TABLE organizations DROP COLUMN IF EXISTS default_org_member_roles;
diff --git a/coderd/database/migrations/000516_org_default_member_roles.up.sql b/coderd/database/migrations/000516_org_default_member_roles.up.sql
new file mode 100644
index 00000000000..007e4dd4e89
--- /dev/null
+++ b/coderd/database/migrations/000516_org_default_member_roles.up.sql
@@ -0,0 +1,16 @@
+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.';
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 8a6aa5fd4dd..f7ee4b65d4b 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -5202,6 +5202,8 @@ type Organization struct {
Deleted bool `db:"deleted" json:"deleted"`
// Controls whose workspaces can be shared: none, everyone, or service_accounts.
ShareableWorkspaceOwners ShareableWorkspaceOwners `db:"shareable_workspace_owners" json:"shareable_workspace_owners"`
+ // 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.
+ DefaultOrgMemberRoles []string `db:"default_org_member_roles" json:"default_org_member_roles"`
}
type OrganizationMember struct {
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index 3a6e2ac6397..bc884a07527 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -3036,6 +3036,62 @@ func TestGetAuthorizationUserRolesImpliedOrgRole(t *testing.T) {
require.NotContains(t, saRoles.Roles, wantMember)
}
+// TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles verifies the
+// resolve-at-read semantics for organizations.default_org_member_roles:
+// every member's effective roles include the org's defaults, and changes
+// to the column propagate on the next request. The union applies to
+// regular users and to service accounts; the SQL array_cats the column
+// for both code paths.
+func TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles(t *testing.T) {
+ t.Parallel()
+
+ db, _ := dbtestutil.NewDB(t)
+ org := dbgen.Organization(t, db, database.Organization{})
+ user := dbgen.User(t, db, database.User{})
+ saUser := dbgen.User(t, db, database.User{IsServiceAccount: true})
+ dbgen.OrganizationMember(t, db, database.OrganizationMember{
+ OrganizationID: org.ID,
+ UserID: user.ID,
+ })
+ dbgen.OrganizationMember(t, db, database.OrganizationMember{
+ OrganizationID: org.ID,
+ UserID: saUser.ID,
+ })
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ // New orgs default to organization-workspace-access; both the regular
+ // user's and the service account's effective roles must include the
+ // scoped form.
+ wantWorkspaceAccess := rbac.RoleOrgWorkspaceAccess() + ":" + org.ID.String()
+ initial, err := db.GetAuthorizationUserRoles(ctx, user.ID)
+ require.NoError(t, err)
+ require.Contains(t, initial.Roles, wantWorkspaceAccess)
+ initialSA, err := db.GetAuthorizationUserRoles(ctx, saUser.ID)
+ require.NoError(t, err)
+ require.Contains(t, initialSA.Roles, wantWorkspaceAccess)
+
+ // Shrinking the org default to empty must immediately drop the role
+ // from both effective sets.
+ _, err = db.UpdateOrganization(ctx, database.UpdateOrganizationParams{
+ ID: org.ID,
+ UpdatedAt: dbtime.Now(),
+ Name: org.Name,
+ DisplayName: org.DisplayName,
+ Description: org.Description,
+ Icon: org.Icon,
+ DefaultOrgMemberRoles: []string{},
+ })
+ require.NoError(t, err)
+
+ shrunk, err := db.GetAuthorizationUserRoles(ctx, user.ID)
+ require.NoError(t, err)
+ require.NotContains(t, shrunk.Roles, wantWorkspaceAccess)
+ shrunkSA, err := db.GetAuthorizationUserRoles(ctx, saUser.ID)
+ require.NoError(t, err)
+ require.NotContains(t, shrunkSA.Roles, wantWorkspaceAccess)
+}
+
func TestUpdateOrganizationWorkspaceSharingSettings(t *testing.T) {
t.Parallel()
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 8b118061b17..f7901d6ae1b 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -18319,7 +18319,7 @@ func (q *sqlQuerier) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRole
const getDefaultOrganization = `-- name: GetDefaultOrganization :one
SELECT
- id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
+ id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
FROM
organizations
WHERE
@@ -18342,13 +18342,14 @@ func (q *sqlQuerier) GetDefaultOrganization(ctx context.Context) (Organization,
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
const getOrganizationByID = `-- name: GetOrganizationByID :one
SELECT
- id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
+ id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
FROM
organizations
WHERE
@@ -18369,13 +18370,14 @@ func (q *sqlQuerier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Org
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
const getOrganizationByName = `-- name: GetOrganizationByName :one
SELECT
- id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
+ id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
FROM
organizations
WHERE
@@ -18405,6 +18407,7 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, arg GetOrganizat
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
@@ -18475,7 +18478,7 @@ func (q *sqlQuerier) GetOrganizationResourceCountByID(ctx context.Context, organ
const getOrganizations = `-- name: GetOrganizations :many
SELECT
- id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
+ id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
FROM
organizations
WHERE
@@ -18520,6 +18523,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsP
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
); err != nil {
return nil, err
}
@@ -18536,7 +18540,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsP
const getOrganizationsByUserID = `-- name: GetOrganizationsByUserID :many
SELECT
- id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
+ id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
FROM
organizations
WHERE
@@ -18582,6 +18586,7 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, arg GetOrgani
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
); err != nil {
return nil, err
}
@@ -18598,20 +18603,21 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, arg GetOrgani
const insertOrganization = `-- name: InsertOrganization :one
INSERT INTO
- organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default)
+ organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default, default_org_member_roles)
VALUES
-- If no organizations exist, and this is the first, make it the default.
- ($1, $2, $3, $4, $5, $6, $7, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
+ ($1, $2, $3, $4, $5, $6, $7, (SELECT TRUE FROM organizations LIMIT 1) IS NULL, $8) RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
`
type InsertOrganizationParams struct {
- ID uuid.UUID `db:"id" json:"id"`
- Name string `db:"name" json:"name"`
- DisplayName string `db:"display_name" json:"display_name"`
- Description string `db:"description" json:"description"`
- Icon string `db:"icon" json:"icon"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ ID uuid.UUID `db:"id" json:"id"`
+ Name string `db:"name" json:"name"`
+ DisplayName string `db:"display_name" json:"display_name"`
+ Description string `db:"description" json:"description"`
+ Icon string `db:"icon" json:"icon"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ DefaultOrgMemberRoles []string `db:"default_org_member_roles" json:"default_org_member_roles"`
}
func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) {
@@ -18623,6 +18629,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat
arg.Icon,
arg.CreatedAt,
arg.UpdatedAt,
+ pq.Array(arg.DefaultOrgMemberRoles),
)
var i Organization
err := row.Scan(
@@ -18636,6 +18643,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
@@ -18648,19 +18656,21 @@ SET
name = $2,
display_name = $3,
description = $4,
- icon = $5
+ icon = $5,
+ default_org_member_roles = $6
WHERE
- id = $6
-RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
+ id = $7
+RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
`
type UpdateOrganizationParams struct {
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
- Name string `db:"name" json:"name"`
- DisplayName string `db:"display_name" json:"display_name"`
- Description string `db:"description" json:"description"`
- Icon string `db:"icon" json:"icon"`
- ID uuid.UUID `db:"id" json:"id"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Name string `db:"name" json:"name"`
+ DisplayName string `db:"display_name" json:"display_name"`
+ Description string `db:"description" json:"description"`
+ Icon string `db:"icon" json:"icon"`
+ DefaultOrgMemberRoles []string `db:"default_org_member_roles" json:"default_org_member_roles"`
+ ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error) {
@@ -18670,6 +18680,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat
arg.DisplayName,
arg.Description,
arg.Icon,
+ pq.Array(arg.DefaultOrgMemberRoles),
arg.ID,
)
var i Organization
@@ -18684,6 +18695,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
@@ -18716,7 +18728,7 @@ SET
updated_at = $2
WHERE
id = $3
-RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
+RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
`
type UpdateOrganizationWorkspaceSharingSettingsParams struct {
@@ -18739,6 +18751,7 @@ func (q *sqlQuerier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Cont
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
@@ -27898,21 +27911,28 @@ SELECT
-- Concatenating the organization id scopes the organization roles.
array_agg(org_roles || ':' || organization_members.organization_id::text)
FROM
- organization_members,
+ organization_members
+ JOIN organizations ON organizations.id = organization_members.organization_id,
-- All org members get an implied role for their orgs. Most members
-- get organization-member, but service accounts will get
-- organization-service-account instead. They're largely the same,
-- but having them be distinct means we can allow configuring
- -- service-accounts to have slightly broader permissions–such as
+ -- service-accounts to have slightly broader permissions, such as
-- for workspace sharing.
+ --
+ -- organizations.default_org_member_roles is unioned in so changes
+ -- to org defaults propagate to every member on the next request.
unnest(
- array_append(
- roles,
- CASE WHEN users.is_service_account THEN
- 'organization-service-account'
- ELSE
- 'organization-member'
- END
+ array_cat(
+ array_append(
+ roles,
+ CASE WHEN users.is_service_account THEN
+ 'organization-service-account'
+ ELSE
+ 'organization-member'
+ END
+ ),
+ organizations.default_org_member_roles
)
) AS org_roles
WHERE
@@ -27933,7 +27953,7 @@ SELECT
FROM
users
WHERE
- id = $1
+ users.id = $1
`
type GetAuthorizationUserRolesRow struct {
diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql
index 8f27330e9ea..7c71c6b2bfb 100644
--- a/coderd/database/queries/organizations.sql
+++ b/coderd/database/queries/organizations.sql
@@ -116,10 +116,10 @@ SELECT
-- name: InsertOrganization :one
INSERT INTO
- organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default)
+ organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default, default_org_member_roles)
VALUES
-- If no organizations exist, and this is the first, make it the default.
- (@id, @name, @display_name, @description, @icon, @created_at, @updated_at, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *;
+ (@id, @name, @display_name, @description, @icon, @created_at, @updated_at, (SELECT TRUE FROM organizations LIMIT 1) IS NULL, @default_org_member_roles) RETURNING *;
-- name: UpdateOrganization :one
UPDATE
@@ -129,7 +129,8 @@ SET
name = @name,
display_name = @display_name,
description = @description,
- icon = @icon
+ icon = @icon,
+ default_org_member_roles = @default_org_member_roles
WHERE
id = @id
RETURNING *;
diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql
index 7bbd2dd0c97..92dc26a4d7d 100644
--- a/coderd/database/queries/users.sql
+++ b/coderd/database/queries/users.sql
@@ -609,21 +609,28 @@ SELECT
-- Concatenating the organization id scopes the organization roles.
array_agg(org_roles || ':' || organization_members.organization_id::text)
FROM
- organization_members,
+ organization_members
+ JOIN organizations ON organizations.id = organization_members.organization_id,
-- All org members get an implied role for their orgs. Most members
-- get organization-member, but service accounts will get
-- organization-service-account instead. They're largely the same,
-- but having them be distinct means we can allow configuring
- -- service-accounts to have slightly broader permissions–such as
+ -- service-accounts to have slightly broader permissions, such as
-- for workspace sharing.
+ --
+ -- organizations.default_org_member_roles is unioned in so changes
+ -- to org defaults propagate to every member on the next request.
unnest(
- array_append(
- roles,
- CASE WHEN users.is_service_account THEN
- 'organization-service-account'
- ELSE
- 'organization-member'
- END
+ array_cat(
+ array_append(
+ roles,
+ CASE WHEN users.is_service_account THEN
+ 'organization-service-account'
+ ELSE
+ 'organization-member'
+ END
+ ),
+ organizations.default_org_member_roles
)
) AS org_roles
WHERE
@@ -644,7 +651,7 @@ SELECT
FROM
users
WHERE
- id = @user_id;
+ users.id = @user_id;
-- name: UpdateUserQuietHoursSchedule :one
UPDATE
diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go
index 529ba947745..dc04d1c519b 100644
--- a/coderd/httpmw/authorize_test.go
+++ b/coderd/httpmw/authorize_test.go
@@ -50,11 +50,12 @@ func TestExtractUserRoles(t *testing.T) {
roles := []string{}
user, token := addUser(t, db, roles...)
org, err := db.InsertOrganization(context.Background(), database.InsertOrganizationParams{
- ID: uuid.New(),
- Name: "testorg",
- Description: "test",
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
+ ID: uuid.New(),
+ Name: "testorg",
+ Description: "test",
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ DefaultOrgMemberRoles: rbac.DefaultOrgMemberRoles(),
})
require.NoError(t, err)
@@ -67,7 +68,7 @@ func TestExtractUserRoles(t *testing.T) {
Roles: orgRoles,
})
require.NoError(t, err)
- return user, []rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID)}, token
+ return user, []rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID), rbac.ScopedRoleOrgWorkspaceAccess(org.ID)}, token
},
},
{
@@ -78,11 +79,12 @@ func TestExtractUserRoles(t *testing.T) {
expected = append(expected, rbac.RoleMember())
for i := 0; i < 3; i++ {
organization, err := db.InsertOrganization(context.Background(), database.InsertOrganizationParams{
- ID: uuid.New(),
- Name: fmt.Sprintf("testorg%d", i),
- Description: "test",
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
+ ID: uuid.New(),
+ Name: fmt.Sprintf("testorg%d", i),
+ Description: "test",
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ DefaultOrgMemberRoles: rbac.DefaultOrgMemberRoles(),
})
require.NoError(t, err)
@@ -100,6 +102,7 @@ func TestExtractUserRoles(t *testing.T) {
})
require.NoError(t, err)
expected = append(expected, rbac.ScopedRoleOrgMember(organization.ID))
+ expected = append(expected, rbac.ScopedRoleOrgWorkspaceAccess(organization.ID))
}
return user, expected, token
},
diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go
index 72101b89ca8..ce0571e8f19 100644
--- a/coderd/httpmw/organizationparam_test.go
+++ b/coderd/httpmw/organizationparam_test.go
@@ -116,10 +116,11 @@ func TestOrganizationParam(t *testing.T) {
rtr = chi.NewRouter()
)
organization, err := db.InsertOrganization(r.Context(), database.InsertOrganizationParams{
- ID: uuid.New(),
- Name: "test",
- CreatedAt: dbtime.Now(),
- UpdatedAt: dbtime.Now(),
+ ID: uuid.New(),
+ Name: "test",
+ CreatedAt: dbtime.Now(),
+ UpdatedAt: dbtime.Now(),
+ DefaultOrgMemberRoles: rbac.DefaultOrgMemberRoles(),
})
require.NoError(t, err)
chi.RouteContext(r.Context()).URLParams.Add("organization", organization.ID.String())
diff --git a/coderd/idpsync/role.go b/coderd/idpsync/role.go
index 230622e3fbd..410c1f8b973 100644
--- a/coderd/idpsync/role.go
+++ b/coderd/idpsync/role.go
@@ -179,15 +179,29 @@ func (s AGPLIDPSync) SyncRoles(ctx context.Context, db database.Store, user data
validExpected = append(validExpected, role.Name)
}
}
- // Ignore the implied member role
- validExpected = slices.DeleteFunc(validExpected, func(s string) bool {
- return s == rbac.RoleOrgMember()
- })
+
+ // The implicit role set (organization-member plus the org's
+ // default_org_member_roles) is applied at request time by
+ // GetAuthorizationUserRoles. Filter both sides of the diff so
+ // IdP sync neither tries to grant implicit roles explicitly nor
+ // remove them.
+ org, err := tx.GetOrganizationByID(ctx, orgID)
+ if err != nil {
+ return xerrors.Errorf("get organization %s for default roles: %w", orgID, err)
+ }
+ implicit := make(map[string]struct{}, len(org.DefaultOrgMemberRoles)+1)
+ implicit[rbac.RoleOrgMember()] = struct{}{}
+ for _, r := range org.DefaultOrgMemberRoles {
+ implicit[r] = struct{}{}
+ }
+ isImplicit := func(s string) bool {
+ _, ok := implicit[s]
+ return ok
+ }
+ validExpected = slices.DeleteFunc(validExpected, isImplicit)
existingFound := existingRoles[orgID]
- existingFound = slices.DeleteFunc(existingFound, func(s string) bool {
- return s == rbac.RoleOrgMember()
- })
+ existingFound = slices.DeleteFunc(existingFound, isImplicit)
// Only care about unique roles. So remove all duplicates
existingFound = slice.Unique(existingFound)
diff --git a/coderd/idpsync/role_test.go b/coderd/idpsync/role_test.go
index ccbd2c0b5a2..6ec082d4e73 100644
--- a/coderd/idpsync/role_test.go
+++ b/coderd/idpsync/role_test.go
@@ -333,6 +333,12 @@ func TestNoopNoDiff(t *testing.T) {
},
}, nil)
+ // SyncRoles fetches the org to union implicit roles into the diff filter.
+ mDB.EXPECT().GetOrganizationByID(gomock.Any(), orgID).Return(database.Organization{
+ ID: orgID,
+ DefaultOrgMemberRoles: []string{},
+ }, nil)
+
mDB.EXPECT().GetRuntimeConfig(gomock.Any(), gomock.Any()).Return(
string(must(json.Marshal(idpsync.RoleSyncSettings{
Field: "roles",
diff --git a/coderd/provisionerdserver/acquirer_test.go b/coderd/provisionerdserver/acquirer_test.go
index 817bae45bbd..0f724ad173e 100644
--- a/coderd/provisionerdserver/acquirer_test.go
+++ b/coderd/provisionerdserver/acquirer_test.go
@@ -23,6 +23,7 @@ import (
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/provisionerdserver"
+ "github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/testutil"
)
@@ -473,11 +474,12 @@ func TestAcquirer_MatchTags(t *testing.T) {
db, ps := dbtestutil.NewDB(t)
log := testutil.Logger(t)
org, err := db.InsertOrganization(ctx, database.InsertOrganizationParams{
- ID: uuid.New(),
- Name: "test org",
- Description: "the organization of testing",
- CreatedAt: dbtime.Now(),
- UpdatedAt: dbtime.Now(),
+ ID: uuid.New(),
+ Name: "test org",
+ Description: "the organization of testing",
+ CreatedAt: dbtime.Now(),
+ UpdatedAt: dbtime.Now(),
+ DefaultOrgMemberRoles: rbac.DefaultOrgMemberRoles(),
})
require.NoError(t, err)
pj, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go
index 007c26cb18e..e6ad6f74eb0 100644
--- a/coderd/provisionerdserver/provisionerdserver_test.go
+++ b/coderd/provisionerdserver/provisionerdserver_test.go
@@ -626,7 +626,7 @@ func TestAcquireJob(t *testing.T) {
WorkspaceOwnerSshPrivateKey: sshKey.PrivateKey,
WorkspaceBuildId: build.ID.String(),
WorkspaceOwnerLoginType: string(user.LoginType),
- WorkspaceOwnerRbacRoles: []*sdkproto.Role{{Name: rbac.RoleOrgMember(), OrgId: pd.OrganizationID.String()}, {Name: "member", OrgId: ""}, {Name: rbac.RoleOrgAuditor(), OrgId: pd.OrganizationID.String()}},
+ WorkspaceOwnerRbacRoles: []*sdkproto.Role{{Name: rbac.RoleOrgMember(), OrgId: pd.OrganizationID.String()}, {Name: "member", OrgId: ""}, {Name: rbac.RoleOrgAuditor(), OrgId: pd.OrganizationID.String()}, {Name: rbac.RoleOrgWorkspaceAccess(), OrgId: pd.OrganizationID.String()}},
TaskId: task.ID.String(),
TaskPrompt: task.Prompt,
}
diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go
index f13e660b640..80610d53de8 100644
--- a/coderd/rbac/roles.go
+++ b/coderd/rbac/roles.go
@@ -211,6 +211,17 @@ func ScopedRoleOrgWorkspaceAccess(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgWorkspaceAccess(), OrganizationID: organizationID}
}
+// 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.
+func DefaultOrgMemberRoles() []string {
+ return []string{orgWorkspaceAccess}
+}
+
// OrgWorkspaceAccessMemberPerms returns the elevation perms granted by the
// organization-workspace-access role.
func OrgWorkspaceAccessMemberPerms() []Permission {
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 68d6ae0f7cb..0d8a07e825b 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -5004,6 +5004,7 @@ const (
ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality.
ExperimentWorkspaceBuildUpdates Experiment = "workspace-build-updates" // Enables publishing workspace build updates to the all builds pubsub channel.
ExperimentNATSPubsub Experiment = "nats_pubsub" // Enables embedded NATS pubsub.
+ ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.
)
func (e Experiment) DisplayName() string {
@@ -5024,6 +5025,8 @@ func (e Experiment) DisplayName() string {
return "Workspace Build Updates Channel"
case ExperimentNATSPubsub:
return "NATS Pubsub"
+ case ExperimentMinimumImplicitMember:
+ return "Gateway Accounts (minimum implicit member)"
default:
// Split on hyphen and convert to title case
// e.g. "mcp-server-http" -> "Mcp Server Http"
@@ -5042,6 +5045,7 @@ var ExperimentsKnown = Experiments{
ExperimentMCPServerHTTP,
ExperimentNATSPubsub,
ExperimentWorkspaceBuildUpdates,
+ ExperimentMinimumImplicitMember,
}
// ExperimentsSafe should include all experiments that are safe for
diff --git a/codersdk/organizations.go b/codersdk/organizations.go
index 8c17b50e569..63ea3cd0c3b 100644
--- a/codersdk/organizations.go
+++ b/codersdk/organizations.go
@@ -55,6 +55,10 @@ type Organization struct {
CreatedAt time.Time `table:"created at" json:"created_at" validate:"required" format:"date-time"`
UpdatedAt time.Time `table:"updated at" json:"updated_at" validate:"required" format:"date-time"`
IsDefault bool `table:"default" json:"is_default" validate:"required"`
+ // DefaultOrgMemberRoles are unioned into every member's effective
+ // roles at request time. Changes propagate to all members on the
+ // next request.
+ DefaultOrgMemberRoles []string `table:"default org member roles" json:"default_org_member_roles"`
}
func (o Organization) HumanName() string {
@@ -113,6 +117,9 @@ type UpdateOrganizationRequest struct {
DisplayName string `json:"display_name,omitempty" validate:"omitempty,organization_display_name"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
+ // DefaultOrgMemberRoles, when non-nil, replaces the org's default
+ // member roles.
+ DefaultOrgMemberRoles *[]string `json:"default_org_member_roles,omitempty"`
}
// CreateTemplateVersionRequest enables callers to create a new Template Version.
diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md
index 45022ff2530..0916c4550d0 100644
--- a/docs/admin/security/audit-logs.md
+++ b/docs/admin/security/audit-logs.md
@@ -34,7 +34,7 @@ We track the following resources:
| NotificationsSettings
|
| Field | Tracked |
| | id | false |
| notifier_paused | true |
|
| OAuth2ProviderApp
| | Field | Tracked |
| | callback_url | true |
| client_id_issued_at | false |
| client_secret_expires_at | true |
| client_type | true |
| client_uri | true |
| contacts | true |
| created_at | false |
| dynamically_registered | true |
| grant_types | true |
| icon | true |
| id | false |
| jwks | true |
| jwks_uri | true |
| logo_uri | true |
| name | true |
| policy_uri | true |
| redirect_uris | true |
| registration_access_token | true |
| registration_client_uri | true |
| response_types | true |
| scope | true |
| software_id | true |
| software_version | true |
| token_endpoint_auth_method | true |
| tos_uri | true |
| updated_at | false |
|
| OAuth2ProviderAppSecret
| | Field | Tracked |
| | app_id | false |
| created_at | false |
| display_secret | false |
| hashed_secret | false |
| id | false |
| last_used_at | false |
| secret_prefix | false |
|
-| Organization
| | Field | Tracked |
| | created_at | false |
| deleted | true |
| description | true |
| display_name | true |
| icon | true |
| id | false |
| is_default | true |
| name | true |
| shareable_workspace_owners | true |
| updated_at | true |
|
+| Organization
| | Field | Tracked |
| | created_at | false |
| default_org_member_roles | true |
| deleted | true |
| description | true |
| display_name | true |
| icon | true |
| id | false |
| is_default | true |
| name | true |
| shareable_workspace_owners | true |
| updated_at | true |
|
| OrganizationSyncSettings
| | Field | Tracked |
| | assign_default | true |
| field | true |
| mapping | true |
|
| PrebuildsSettings
| | Field | Tracked |
| | id | false |
| reconciliation_paused | true |
|
| RoleSyncSettings
| | Field | Tracked |
| | field | true |
| mapping | true |
|
diff --git a/docs/reference/api/organizations.md b/docs/reference/api/organizations.md
index dbbe6b4fe52..c0dcb219260 100644
--- a/docs/reference/api/organizations.md
+++ b/docs/reference/api/organizations.md
@@ -21,6 +21,9 @@ curl -X GET http://coder-server:8080/api/v2/organizations \
[
{
"created_at": "2019-08-24T14:15:22Z",
+ "default_org_member_roles": [
+ "string"
+ ],
"description": "string",
"display_name": "string",
"icon": "string",
@@ -42,17 +45,18 @@ curl -X GET http://coder-server:8080/api/v2/organizations \
Status Code **200**
-| Name | Type | Required | Restrictions | Description |
-|------------------|-------------------|----------|--------------|-------------|
-| `[array item]` | array | false | | |
-| `» created_at` | string(date-time) | true | | |
-| `» description` | string | false | | |
-| `» display_name` | string | false | | |
-| `» icon` | string | false | | |
-| `» id` | string(uuid) | true | | |
-| `» is_default` | boolean | true | | |
-| `» name` | string | false | | |
-| `» updated_at` | string(date-time) | true | | |
+| Name | Type | Required | Restrictions | Description |
+|------------------------------|-------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
+| `[array item]` | array | false | | |
+| `» created_at` | string(date-time) | true | | |
+| `» default_org_member_roles` | array | false | | Default org member roles are unioned into every member's effective roles at request time. Changes propagate to all members on the next request. |
+| `» description` | string | false | | |
+| `» display_name` | string | false | | |
+| `» icon` | string | false | | |
+| `» id` | string(uuid) | true | | |
+| `» is_default` | boolean | true | | |
+| `» name` | string | false | | |
+| `» updated_at` | string(date-time) | true | | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -94,6 +98,9 @@ curl -X POST http://coder-server:8080/api/v2/organizations \
```json
{
"created_at": "2019-08-24T14:15:22Z",
+ "default_org_member_roles": [
+ "string"
+ ],
"description": "string",
"display_name": "string",
"icon": "string",
@@ -138,6 +145,9 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization} \
```json
{
"created_at": "2019-08-24T14:15:22Z",
+ "default_org_member_roles": [
+ "string"
+ ],
"description": "string",
"display_name": "string",
"icon": "string",
@@ -218,6 +228,9 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization} \
```json
{
+ "default_org_member_roles": [
+ "string"
+ ],
"description": "string",
"display_name": "string",
"icon": "string",
@@ -239,6 +252,9 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization} \
```json
{
"created_at": "2019-08-24T14:15:22Z",
+ "default_org_member_roles": [
+ "string"
+ ],
"description": "string",
"display_name": "string",
"icon": "string",
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 36ed488d480..642d36fc75e 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -7219,9 +7219,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
#### Enumerated Values
-| Value(s) |
-|----------------------------------------------------------------------------------------------------------------------------------------------|
-| `auto-fill-parameters`, `example`, `mcp-server-http`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` |
+| Value(s) |
+|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `auto-fill-parameters`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` |
## codersdk.ExternalAPIKeyScopes
@@ -9149,6 +9149,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
```json
{
"created_at": "2019-08-24T14:15:22Z",
+ "default_org_member_roles": [
+ "string"
+ ],
"description": "string",
"display_name": "string",
"icon": "string",
@@ -9161,16 +9164,17 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
### Properties
-| Name | Type | Required | Restrictions | Description |
-|----------------|---------|----------|--------------|-------------|
-| `created_at` | string | true | | |
-| `description` | string | false | | |
-| `display_name` | string | false | | |
-| `icon` | string | false | | |
-| `id` | string | true | | |
-| `is_default` | boolean | true | | |
-| `name` | string | false | | |
-| `updated_at` | string | true | | |
+| Name | Type | Required | Restrictions | Description |
+|----------------------------|-----------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
+| `created_at` | string | true | | |
+| `default_org_member_roles` | array of string | false | | Default org member roles are unioned into every member's effective roles at request time. Changes propagate to all members on the next request. |
+| `description` | string | false | | |
+| `display_name` | string | false | | |
+| `icon` | string | false | | |
+| `id` | string | true | | |
+| `is_default` | boolean | true | | |
+| `name` | string | false | | |
+| `updated_at` | string | true | | |
## codersdk.OrganizationMember
@@ -13196,6 +13200,9 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
```json
{
+ "default_org_member_roles": [
+ "string"
+ ],
"description": "string",
"display_name": "string",
"icon": "string",
@@ -13205,12 +13212,13 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
### Properties
-| Name | Type | Required | Restrictions | Description |
-|----------------|--------|----------|--------------|-------------|
-| `description` | string | false | | |
-| `display_name` | string | false | | |
-| `icon` | string | false | | |
-| `name` | string | false | | |
+| Name | Type | Required | Restrictions | Description |
+|----------------------------|-----------------|----------|--------------|---------------------------------------------------------------------------------|
+| `default_org_member_roles` | array of string | false | | Default org member roles when non-nil, replaces the org's default member roles. |
+| `description` | string | false | | |
+| `display_name` | string | false | | |
+| `icon` | string | false | | |
+| `name` | string | false | | |
## codersdk.UpdateRoles
diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md
index c82ca65701d..1ba07d48b4e 100644
--- a/docs/reference/api/users.md
+++ b/docs/reference/api/users.md
@@ -1168,6 +1168,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations \
[
{
"created_at": "2019-08-24T14:15:22Z",
+ "default_org_member_roles": [
+ "string"
+ ],
"description": "string",
"display_name": "string",
"icon": "string",
@@ -1189,17 +1192,18 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations \
Status Code **200**
-| Name | Type | Required | Restrictions | Description |
-|------------------|-------------------|----------|--------------|-------------|
-| `[array item]` | array | false | | |
-| `» created_at` | string(date-time) | true | | |
-| `» description` | string | false | | |
-| `» display_name` | string | false | | |
-| `» icon` | string | false | | |
-| `» id` | string(uuid) | true | | |
-| `» is_default` | boolean | true | | |
-| `» name` | string | false | | |
-| `» updated_at` | string(date-time) | true | | |
+| Name | Type | Required | Restrictions | Description |
+|------------------------------|-------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
+| `[array item]` | array | false | | |
+| `» created_at` | string(date-time) | true | | |
+| `» default_org_member_roles` | array | false | | Default org member roles are unioned into every member's effective roles at request time. Changes propagate to all members on the next request. |
+| `» description` | string | false | | |
+| `» display_name` | string | false | | |
+| `» icon` | string | false | | |
+| `» id` | string(uuid) | true | | |
+| `» is_default` | boolean | true | | |
+| `» name` | string | false | | |
+| `» updated_at` | string(date-time) | true | | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -1230,6 +1234,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations/{organiza
```json
{
"created_at": "2019-08-24T14:15:22Z",
+ "default_org_member_roles": [
+ "string"
+ ],
"description": "string",
"display_name": "string",
"icon": "string",
diff --git a/docs/reference/cli/organizations_list.md b/docs/reference/cli/organizations_list.md
index 5f866caf5a4..c1335b7f8b1 100644
--- a/docs/reference/cli/organizations_list.md
+++ b/docs/reference/cli/organizations_list.md
@@ -23,10 +23,10 @@ List all organizations. Requires a role which grants ResourceOrganization: read.
### -c, --column
-| | |
-|---------|-------------------------------------------------------------------------------------------|
-| Type | [id\|name\|display name\|icon\|description\|created at\|updated at\|default] |
-| Default | name,display name,id,default |
+| | |
+|---------|---------------------------------------------------------------------------------------------------------------------|
+| Type | [id\|name\|display name\|icon\|description\|created at\|updated at\|default\|default org member roles] |
+| Default | name,display name,id,default |
Columns to display in table output.
diff --git a/docs/reference/cli/organizations_show.md b/docs/reference/cli/organizations_show.md
index 540014b4680..90d5f00be1f 100644
--- a/docs/reference/cli/organizations_show.md
+++ b/docs/reference/cli/organizations_show.md
@@ -41,10 +41,10 @@ Only print the organization ID.
### -c, --column
-| | |
-|---------|-------------------------------------------------------------------------------------------|
-| Type | [id\|name\|display name\|icon\|description\|created at\|updated at\|default] |
-| Default | id,name,default |
+| | |
+|---------|---------------------------------------------------------------------------------------------------------------------|
+| Type | [id\|name\|display name\|icon\|description\|created at\|updated at\|default\|default org member roles] |
+| Default | id,name,default |
Columns to display in table output.
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index 6ae2afd92ca..08d64c67d62 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -341,6 +341,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"display_name": ActionTrack,
"icon": ActionTrack,
"shareable_workspace_owners": ActionTrack,
+ "default_org_member_roles": ActionTrack,
},
&database.NotificationTemplate{}: {
"id": ActionIgnore,
diff --git a/enterprise/coderd/organizations.go b/enterprise/coderd/organizations.go
index fd9f9a4af6f..a63e7228232 100644
--- a/enterprise/coderd/organizations.go
+++ b/enterprise/coderd/organizations.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"net/http"
+ "slices"
"strings"
"github.com/google/uuid"
@@ -16,6 +17,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
+ "github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/codersdk"
)
@@ -60,6 +62,39 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) {
return
}
+ // Deviations from rbac.DefaultOrgMemberRoles require the
+ // minimum-implicit-member experiment.
+ if req.DefaultOrgMemberRoles != nil &&
+ !slices.Equal(*req.DefaultOrgMemberRoles, rbac.DefaultOrgMemberRoles()) &&
+ !api.AGPL.Experiments.Enabled(codersdk.ExperimentMinimumImplicitMember) {
+ httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
+ Message: "Changing default organization roles is not enabled on this deployment.",
+ Detail: fmt.Sprintf("Setting default_org_member_roles to anything other than %v requires the %q experiment.", rbac.DefaultOrgMemberRoles(), codersdk.ExperimentMinimumImplicitMember),
+ })
+ return
+ }
+
+ // default_org_member_roles currently accepts built-in role names only.
+ // Custom (DB-stored) roles are intentionally rejected here so the
+ // caller cannot land a malformed name that would break role expansion
+ // for every member of the org. A future change can extend this to
+ // custom org roles by routing through canAssignRoles in dbauthz.
+ if req.DefaultOrgMemberRoles != nil {
+ for _, name := range *req.DefaultOrgMemberRoles {
+ if _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: name, OrganizationID: organization.ID}); err != nil {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid default_org_member_roles entry.",
+ Detail: fmt.Sprintf("%q is not a built-in role; default_org_member_roles currently accepts built-in role names only.", name),
+ Validations: []codersdk.ValidationError{{
+ Field: "default_org_member_roles",
+ Detail: fmt.Sprintf("%q is not a built-in role.", name),
+ }},
+ })
+ return
+ }
+ }
+ }
+
err := database.ReadModifyUpdate(api.Database, func(tx database.Store) error {
var err error
organization, err = tx.GetOrganizationByID(ctx, organization.ID)
@@ -68,12 +103,13 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) {
}
updateOrgParams := database.UpdateOrganizationParams{
- UpdatedAt: dbtime.Now(),
- ID: organization.ID,
- Name: organization.Name,
- DisplayName: organization.DisplayName,
- Description: organization.Description,
- Icon: organization.Icon,
+ UpdatedAt: dbtime.Now(),
+ ID: organization.ID,
+ Name: organization.Name,
+ DisplayName: organization.DisplayName,
+ Description: organization.Description,
+ Icon: organization.Icon,
+ DefaultOrgMemberRoles: organization.DefaultOrgMemberRoles,
}
if req.Name != "" {
@@ -88,6 +124,9 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) {
if req.Icon != nil {
updateOrgParams.Icon = *req.Icon
}
+ if req.DefaultOrgMemberRoles != nil {
+ updateOrgParams.DefaultOrgMemberRoles = *req.DefaultOrgMemberRoles
+ }
organization, err = tx.UpdateOrganization(ctx, updateOrgParams)
if err != nil {
@@ -280,13 +319,14 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) {
}
organization, err = tx.InsertOrganization(ctx, database.InsertOrganizationParams{
- ID: organizationID,
- Name: req.Name,
- DisplayName: req.DisplayName,
- Description: req.Description,
- Icon: req.Icon,
- CreatedAt: dbtime.Now(),
- UpdatedAt: dbtime.Now(),
+ ID: organizationID,
+ Name: req.Name,
+ DisplayName: req.DisplayName,
+ Description: req.Description,
+ Icon: req.Icon,
+ CreatedAt: dbtime.Now(),
+ UpdatedAt: dbtime.Now(),
+ DefaultOrgMemberRoles: rbac.DefaultOrgMemberRoles(),
})
if err != nil {
return xerrors.Errorf("create organization: %w", err)
diff --git a/enterprise/coderd/organizations_test.go b/enterprise/coderd/organizations_test.go
index e7b01b0163c..97e20909896 100644
--- a/enterprise/coderd/organizations_test.go
+++ b/enterprise/coderd/organizations_test.go
@@ -9,6 +9,7 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
+ "github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
@@ -448,6 +449,107 @@ func TestPatchOrganizationsByUser(t *testing.T) {
})
require.ErrorContains(t, err, "Multiple Organizations is a Premium feature")
})
+
+ t.Run("DefaultOrgMemberRoles", func(t *testing.T) {
+ t.Parallel()
+
+ t.Run("EqualToDefaultAllowedWithoutExperiment", func(t *testing.T) {
+ t.Parallel()
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureMultipleOrganizations: 1,
+ },
+ },
+ })
+ ctx := testutil.Context(t, testutil.WaitMedium)
+ o := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
+
+ // Writing exactly the deployment default is a no-op and must be allowed.
+ //nolint:gocritic // Only owners can update organization settings.
+ updated, err := client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{
+ DefaultOrgMemberRoles: ptr.Ref(rbac.DefaultOrgMemberRoles()),
+ })
+ require.NoError(t, err)
+ require.Equal(t, rbac.DefaultOrgMemberRoles(), updated.DefaultOrgMemberRoles)
+ })
+
+ t.Run("DeviationRejectedWithoutExperiment", func(t *testing.T) {
+ t.Parallel()
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureMultipleOrganizations: 1,
+ },
+ },
+ })
+ ctx := testutil.Context(t, testutil.WaitMedium)
+ o := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
+
+ // Empty array represents a Gateway Accounts organization. Without
+ // the experiment, this must be rejected.
+ //nolint:gocritic // Only owners can update organization settings.
+ _, err := client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{
+ DefaultOrgMemberRoles: ptr.Ref([]string{}),
+ })
+ var apiErr *codersdk.Error
+ require.ErrorAs(t, err, &apiErr)
+ require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
+ require.Contains(t, apiErr.Message, "Changing default organization roles is not enabled")
+ })
+
+ t.Run("DeviationAllowedWithExperiment", func(t *testing.T) {
+ t.Parallel()
+ dv := coderdtest.DeploymentValues(t)
+ dv.Experiments = []string{string(codersdk.ExperimentMinimumImplicitMember)}
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{DeploymentValues: dv},
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureMultipleOrganizations: 1,
+ },
+ },
+ })
+ ctx := testutil.Context(t, testutil.WaitMedium)
+ o := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
+
+ //nolint:gocritic // Only owners can update organization settings.
+ updated, err := client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{
+ DefaultOrgMemberRoles: ptr.Ref([]string{}),
+ })
+ require.NoError(t, err)
+ require.Empty(t, updated.DefaultOrgMemberRoles)
+ })
+
+ t.Run("NonBuiltInRoleRejected", func(t *testing.T) {
+ t.Parallel()
+ dv := coderdtest.DeploymentValues(t)
+ dv.Experiments = []string{string(codersdk.ExperimentMinimumImplicitMember)}
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{DeploymentValues: dv},
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureMultipleOrganizations: 1,
+ },
+ },
+ })
+ ctx := testutil.Context(t, testutil.WaitMedium)
+ o := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
+
+ // A name that does not resolve via rbac.RoleByName (no such
+ // built-in role) must be rejected. This blocks both custom roles
+ // and malformed names like "foo:bar" that would otherwise break
+ // RoleNameFromString downstream.
+ //nolint:gocritic // Only owners can update organization settings.
+ _, err := client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{
+ DefaultOrgMemberRoles: ptr.Ref([]string{"not-a-built-in-role"}),
+ })
+ var apiErr *codersdk.Error
+ require.ErrorAs(t, err, &apiErr)
+ require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
+ require.Contains(t, apiErr.Message, "Invalid default_org_member_roles entry")
+ })
+ })
}
func TestPostOrganizationsByUser(t *testing.T) {
diff --git a/site/src/api/queries/organizations.test.ts b/site/src/api/queries/organizations.test.ts
index 704004db6ec..c2e5a1241bb 100644
--- a/site/src/api/queries/organizations.test.ts
+++ b/site/src/api/queries/organizations.test.ts
@@ -20,6 +20,7 @@ const MockOrg1: Organization = {
created_at: "",
updated_at: "",
is_default: true,
+ default_org_member_roles: ["organization-workspace-access"],
};
const MockOrg2: Organization = {
@@ -31,6 +32,7 @@ const MockOrg2: Organization = {
created_at: "",
updated_at: "",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
};
const templateCreateCheck: AuthorizationCheck = {
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index a07ff74c874..b19bfe47700 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -4393,6 +4393,7 @@ export type Experiment =
| "auto-fill-parameters"
| "example"
| "mcp-server-http"
+ | "minimum-implicit-member"
| "nats_pubsub"
| "notifications"
| "oauth2"
@@ -4403,6 +4404,7 @@ export const Experiments: Experiment[] = [
"auto-fill-parameters",
"example",
"mcp-server-http",
+ "minimum-implicit-member",
"nats_pubsub",
"notifications",
"oauth2",
@@ -6099,6 +6101,12 @@ export interface Organization extends MinimalOrganization {
readonly created_at: string;
readonly updated_at: string;
readonly is_default: boolean;
+ /**
+ * DefaultOrgMemberRoles are unioned into every member's effective
+ * roles at request time. Changes propagate to all members on the
+ * next request.
+ */
+ readonly default_org_member_roles: readonly string[];
}
// From codersdk/organizations.go
@@ -8844,6 +8852,11 @@ export interface UpdateOrganizationRequest {
readonly display_name?: string;
readonly description?: string;
readonly icon?: string;
+ /**
+ * DefaultOrgMemberRoles, when non-nil, replaces the org's default
+ * member roles.
+ */
+ readonly default_org_member_roles?: string[];
}
// From codersdk/users.go
diff --git a/site/src/modules/management/OrganizationSidebarView.stories.tsx b/site/src/modules/management/OrganizationSidebarView.stories.tsx
index 24423c38638..446400f8980 100644
--- a/site/src/modules/management/OrganizationSidebarView.stories.tsx
+++ b/site/src/modules/management/OrganizationSidebarView.stories.tsx
@@ -67,6 +67,7 @@ export const OverflowDropdown: Story = {
created_at: "",
updated_at: "",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
},
{
id: "my-organization-4-id",
@@ -77,6 +78,7 @@ export const OverflowDropdown: Story = {
created_at: "",
updated_at: "",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
},
{
id: "my-organization-5-id",
@@ -87,6 +89,7 @@ export const OverflowDropdown: Story = {
created_at: "",
updated_at: "",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
},
{
id: "my-organization-6-id",
@@ -97,6 +100,7 @@ export const OverflowDropdown: Story = {
created_at: "",
updated_at: "",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
},
{
id: "my-organization-7-id",
@@ -107,6 +111,7 @@ export const OverflowDropdown: Story = {
created_at: "",
updated_at: "",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
},
],
},
diff --git a/site/src/pages/AgentsPage/components/ChatElements/CompactOrgSelector.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/CompactOrgSelector.stories.tsx
index 8c75d4a72d5..c41aa03886d 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/CompactOrgSelector.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/CompactOrgSelector.stories.tsx
@@ -13,6 +13,7 @@ const mockOrgs: Organization[] = [
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-06-01T00:00:00Z",
is_default: true,
+ default_org_member_roles: ["organization-workspace-access"],
},
{
id: "org-acme",
@@ -23,6 +24,7 @@ const mockOrgs: Organization[] = [
created_at: "2024-02-01T00:00:00Z",
updated_at: "2024-06-01T00:00:00Z",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
},
{
id: "org-globex",
@@ -33,6 +35,7 @@ const mockOrgs: Organization[] = [
created_at: "2024-03-01T00:00:00Z",
updated_at: "2024-06-01T00:00:00Z",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
},
];
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index f85f3d47538..2972fc97d44 100644
--- a/site/src/testHelpers/entities.ts
+++ b/site/src/testHelpers/entities.ts
@@ -21,6 +21,7 @@ export const MockOrganization: TypesGen.Organization = {
created_at: "",
updated_at: "",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
};
export const MockDefaultOrganization: TypesGen.Organization = {
@@ -37,6 +38,7 @@ export const MockOrganization2: TypesGen.Organization = {
created_at: "",
updated_at: "",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
};
export const MockOrganization3: TypesGen.Organization = {
@@ -49,6 +51,7 @@ export const MockOrganization3: TypesGen.Organization = {
created_at: "",
updated_at: "",
is_default: false,
+ default_org_member_roles: ["organization-workspace-access"],
};
export const MockTemplateDAUResponse: TypesGen.DAUsResponse = {