diff --git a/cli/server_createadminuser_test.go b/cli/server_createadminuser_test.go
index d0eef5f72d..21fafbc488 100644
--- a/cli/server_createadminuser_test.go
+++ b/cli/server_createadminuser_test.go
@@ -107,17 +107,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 8197886411..188a129e57 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 479182ac75..c3e0bab898 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 7aca308f9d..2b0cc61ea5 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -19101,12 +19101,14 @@ const docTemplate = `{
"workspace-usage",
"oauth2",
"mcp-server-http",
- "workspace-build-updates"
+ "workspace-build-updates",
+ "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.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentOAuth2": "Enables OAuth2 provider functionality.",
"ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.",
@@ -19119,7 +19121,8 @@ const docTemplate = `{
"Enables the new workspace usage tracking.",
"Enables OAuth2 provider functionality.",
"Enables the MCP HTTP server functionality.",
- "Enables publishing workspace build updates to the all builds pubsub channel."
+ "Enables publishing workspace build updates to the all builds pubsub channel.",
+ "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts."
],
"x-enum-varnames": [
"ExperimentExample",
@@ -19128,7 +19131,8 @@ const docTemplate = `{
"ExperimentWorkspaceUsage",
"ExperimentOAuth2",
"ExperimentMCPServerHTTP",
- "ExperimentWorkspaceBuildUpdates"
+ "ExperimentWorkspaceBuildUpdates",
+ "ExperimentMinimumImplicitMember"
]
},
"codersdk.ExternalAPIKeyScopes": {
@@ -20909,6 +20913,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"
},
@@ -24364,6 +24375,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 842eac0c08..e5026be7fb 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -17353,12 +17353,14 @@
"workspace-usage",
"oauth2",
"mcp-server-http",
- "workspace-build-updates"
+ "workspace-build-updates",
+ "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.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentOAuth2": "Enables OAuth2 provider functionality.",
"ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.",
@@ -17371,7 +17373,8 @@
"Enables the new workspace usage tracking.",
"Enables OAuth2 provider functionality.",
"Enables the MCP HTTP server functionality.",
- "Enables publishing workspace build updates to the all builds pubsub channel."
+ "Enables publishing workspace build updates to the all builds pubsub channel.",
+ "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts."
],
"x-enum-varnames": [
"ExperimentExample",
@@ -17380,7 +17383,8 @@
"ExperimentWorkspaceUsage",
"ExperimentOAuth2",
"ExperimentMCPServerHTTP",
- "ExperimentWorkspaceBuildUpdates"
+ "ExperimentWorkspaceBuildUpdates",
+ "ExperimentMinimumImplicitMember"
]
},
"codersdk.ExternalAPIKeyScopes": {
@@ -19086,6 +19090,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"
},
@@ -22401,6 +22412,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 bc93df7cd3..984fdc6b09 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/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go
index 416a2b7257..9703c88306 100644
--- a/coderd/database/dbgen/dbgen.go
+++ b/coderd/database/dbgen/dbgen.go
@@ -1033,13 +1033,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 0bc0874e51..535d5a35ad 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -2372,11 +2372,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/000515_org_default_member_roles.down.sql b/coderd/database/migrations/000515_org_default_member_roles.down.sql
new file mode 100644
index 0000000000..f56201df50
--- /dev/null
+++ b/coderd/database/migrations/000515_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/000515_org_default_member_roles.up.sql b/coderd/database/migrations/000515_org_default_member_roles.up.sql
new file mode 100644
index 0000000000..007e4dd4e8
--- /dev/null
+++ b/coderd/database/migrations/000515_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 b2d7fda98f..be397975c1 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -5200,6 +5200,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 984dd8a79a..7e2fb314db 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -3036,6 +3036,48 @@ 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.
+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{})
+ dbgen.OrganizationMember(t, db, database.OrganizationMember{
+ OrganizationID: org.ID,
+ UserID: user.ID,
+ })
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ // New orgs default to organization-workspace-access; the user'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)
+
+ // Shrinking the org default to empty must immediately drop the role
+ // from the user's effective set without touching organization_members.
+ _, 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)
+}
+
func TestUpdateOrganizationWorkspaceSharingSettings(t *testing.T) {
t.Parallel()
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 28b11009a3..c3e26fca91 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -18310,7 +18310,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
@@ -18333,13 +18333,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
@@ -18360,13 +18361,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
@@ -18396,6 +18398,7 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, arg GetOrganizat
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
@@ -18466,7 +18469,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
@@ -18511,6 +18514,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
}
@@ -18527,7 +18531,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
@@ -18573,6 +18577,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
}
@@ -18589,20 +18594,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) {
@@ -18614,6 +18620,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(
@@ -18627,6 +18634,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
@@ -18639,19 +18647,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) {
@@ -18661,6 +18671,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
@@ -18675,6 +18686,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
@@ -18707,7 +18719,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 {
@@ -18730,6 +18742,7 @@ func (q *sqlQuerier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Cont
&i.Icon,
&i.Deleted,
&i.ShareableWorkspaceOwners,
+ pq.Array(&i.DefaultOrgMemberRoles),
)
return i, err
}
@@ -27854,21 +27867,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
-- 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
@@ -27889,7 +27909,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 8f27330e9e..7c71c6b2bf 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 7bbd2dd0c9..7e068dc152 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
-- 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 529ba94774..885b07e3ea 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 72101b89ca..f83152fe4c 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/provisionerdserver/acquirer_test.go b/coderd/provisionerdserver/acquirer_test.go
index 817bae45bb..effadd077b 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 007c26cb18..e6ad6f74eb 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 6ab121b4a7..87e849dd2e 100644
--- a/coderd/rbac/roles.go
+++ b/coderd/rbac/roles.go
@@ -213,6 +213,11 @@ 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.
+var DefaultOrgMemberRoles = []string{orgWorkspaceAccess}
+
// OrgWorkspaceAccessMemberPerms returns the member-scoped permission set
// for the organization-workspace-access role.
func OrgWorkspaceAccessMemberPerms() []Permission {
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 8164222831..6835d533bd 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -5003,6 +5003,7 @@ const (
ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality.
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.
+ 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 {
@@ -5021,6 +5022,8 @@ func (e Experiment) DisplayName() string {
return "MCP HTTP Server Functionality"
case ExperimentWorkspaceBuildUpdates:
return "Workspace Build Updates Channel"
+ 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"
@@ -5038,6 +5041,7 @@ var ExperimentsKnown = Experiments{
ExperimentOAuth2,
ExperimentMCPServerHTTP,
ExperimentWorkspaceBuildUpdates,
+ ExperimentMinimumImplicitMember,
}
// ExperimentsSafe should include all experiments that are safe for
diff --git a/codersdk/organizations.go b/codersdk/organizations.go
index 8c17b50e56..63ea3cd0c3 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 6e65c5fec5..2f1caea97f 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 dbbe6b4fe5..c0dcb21926 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 47268ba974..cb48b3831a 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -7161,9 +7161,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
#### Enumerated Values
-| Value(s) |
-|-------------------------------------------------------------------------------------------------------------------------------|
-| `auto-fill-parameters`, `example`, `mcp-server-http`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` |
+| Value(s) |
+|----------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `auto-fill-parameters`, `example`, `mcp-server-http`, `minimum-implicit-member`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` |
## codersdk.ExternalAPIKeyScopes
@@ -9091,6 +9091,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",
@@ -9103,16 +9106,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
@@ -13138,6 +13142,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",
@@ -13147,12 +13154,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 c82ca65701..1ba07d48b4 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 5f866caf5a..c1335b7f8b 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 540014b468..90d5f00be1 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 315f6af228..69efd2d6d7 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -340,6 +340,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 fd9f9a4af6..5593c0a8cf 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,20 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) {
return
}
+ // 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.
+ 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: "Gateway Accounts are 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
+ }
+
err := database.ReadModifyUpdate(api.Database, func(tx database.Store) error {
var err error
organization, err = tx.GetOrganizationByID(ctx, organization.ID)
@@ -68,12 +84,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 +105,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 +300,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 e7b01b0163..532b054ce6 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,75 @@ 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.
+ 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.
+ _, 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, "Gateway Accounts are 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{})
+
+ updated, err := client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{
+ DefaultOrgMemberRoles: ptr.Ref([]string{}),
+ })
+ require.NoError(t, err)
+ require.Empty(t, updated.DefaultOrgMemberRoles)
+ })
+ })
}
func TestPostOrganizationsByUser(t *testing.T) {
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index b6d0b79cea..1dd1708ab0 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -4358,6 +4358,7 @@ export type Experiment =
| "auto-fill-parameters"
| "example"
| "mcp-server-http"
+ | "minimum-implicit-member"
| "notifications"
| "oauth2"
| "workspace-build-updates"
@@ -4367,6 +4368,7 @@ export const Experiments: Experiment[] = [
"auto-fill-parameters",
"example",
"mcp-server-http",
+ "minimum-implicit-member",
"notifications",
"oauth2",
"workspace-build-updates",
@@ -6062,6 +6064,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
@@ -8807,6 +8815,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