feat: configurable default org member roles (#25994)

Refs #25936. 
Adds a configurable per-org default member role set. Unioned into each member's effective roles at read time.

<sub>with Coder Agents on behalf of @Emyrk.</sub>
This commit is contained in:
Steven Masley
2026-06-05 14:33:13 -05:00
committed by GitHub
parent 2ef468ef76
commit 938c2080f3
40 changed files with 635 additions and 174 deletions
+10 -8
View File
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+21 -3
View File
@@ -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"
},
+21 -3
View File
@@ -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"
},
+5 -4
View File
@@ -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,
}
}
+63 -3
View File
@@ -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
}
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateOrganization)(ctx, arg)
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 q.db.UpdateOrganization(ctx, arg)
}
func (q *querier) UpdateOrganizationDeletedByID(ctx context.Context, arg database.UpdateOrganizationDeletedByIDParams) error {
+12 -5
View File
@@ -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()
+8 -7
View File
@@ -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")
+4 -1
View File
@@ -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,
@@ -0,0 +1 @@
ALTER TABLE organizations DROP COLUMN IF EXISTS default_org_member_roles;
@@ -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.';
+2
View File
@@ -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 {
+56
View File
@@ -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()
+54 -34
View File
@@ -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 permissionssuch 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 {
+4 -3
View File
@@ -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 *;
+17 -10
View File
@@ -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 permissionssuch 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
+14 -11
View File
@@ -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
},
+5 -4
View File
@@ -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())
+21 -7
View File
@@ -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)
+6
View File
@@ -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",
+7 -5
View File
@@ -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{
@@ -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,
}
+11
View File
@@ -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 {
+4
View File
@@ -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
+7
View File
@@ -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.
+1 -1
View File
@@ -34,7 +34,7 @@ We track the following resources:
| NotificationsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>notifier_paused</td><td>true</td></tr></tbody></table> |
| OAuth2ProviderApp<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>callback_url</td><td>true</td></tr><tr><td>client_id_issued_at</td><td>false</td></tr><tr><td>client_secret_expires_at</td><td>true</td></tr><tr><td>client_type</td><td>true</td></tr><tr><td>client_uri</td><td>true</td></tr><tr><td>contacts</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>dynamically_registered</td><td>true</td></tr><tr><td>grant_types</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwks</td><td>true</td></tr><tr><td>jwks_uri</td><td>true</td></tr><tr><td>logo_uri</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>policy_uri</td><td>true</td></tr><tr><td>redirect_uris</td><td>true</td></tr><tr><td>registration_access_token</td><td>true</td></tr><tr><td>registration_client_uri</td><td>true</td></tr><tr><td>response_types</td><td>true</td></tr><tr><td>scope</td><td>true</td></tr><tr><td>software_id</td><td>true</td></tr><tr><td>software_version</td><td>true</td></tr><tr><td>token_endpoint_auth_method</td><td>true</td></tr><tr><td>tos_uri</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| OAuth2ProviderAppSecret<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>app_id</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>display_secret</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>secret_prefix</td><td>false</td></tr></tbody></table> |
| Organization<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_default</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>shareable_workspace_owners</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr></tbody></table> |
| Organization<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>default_org_member_roles</td><td>true</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_default</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>shareable_workspace_owners</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr></tbody></table> |
| OrganizationSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>assign_default</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
| PrebuildsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>reconciliation_paused</td><td>true</td></tr></tbody></table> |
| RoleSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
+27 -11
View File
@@ -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",
+27 -19
View File
@@ -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
+18 -11
View File
@@ -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",
+4 -4
View File
@@ -23,10 +23,10 @@ List all organizations. Requires a role which grants ResourceOrganization: read.
### -c, --column
| | |
|---------|-------------------------------------------------------------------------------------------|
| Type | <code>[id\|name\|display name\|icon\|description\|created at\|updated at\|default]</code> |
| Default | <code>name,display name,id,default</code> |
| | |
|---------|---------------------------------------------------------------------------------------------------------------------|
| Type | <code>[id\|name\|display name\|icon\|description\|created at\|updated at\|default\|default org member roles]</code> |
| Default | <code>name,display name,id,default</code> |
Columns to display in table output.
+4 -4
View File
@@ -41,10 +41,10 @@ Only print the organization ID.
### -c, --column
| | |
|---------|-------------------------------------------------------------------------------------------|
| Type | <code>[id\|name\|display name\|icon\|description\|created at\|updated at\|default]</code> |
| Default | <code>id,name,default</code> |
| | |
|---------|---------------------------------------------------------------------------------------------------------------------|
| Type | <code>[id\|name\|display name\|icon\|description\|created at\|updated at\|default\|default org member roles]</code> |
| Default | <code>id,name,default</code> |
Columns to display in table output.
+1
View File
@@ -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,
+53 -13
View File
@@ -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)
+102
View File
@@ -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) {
@@ -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 = {
+13
View File
@@ -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
@@ -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"],
},
],
},
@@ -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"],
},
];
+3
View File
@@ -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 = {