mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+21
-3
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+21
-3
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -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.';
|
||||
Generated
+2
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Generated
+54
-34
@@ -18319,7 +18319,7 @@ func (q *sqlQuerier) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRole
|
||||
|
||||
const getDefaultOrganization = `-- name: GetDefaultOrganization :one
|
||||
SELECT
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -18342,13 +18342,14 @@ func (q *sqlQuerier) GetDefaultOrganization(ctx context.Context) (Organization,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
pq.Array(&i.DefaultOrgMemberRoles),
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationByID = `-- name: GetOrganizationByID :one
|
||||
SELECT
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -18369,13 +18370,14 @@ func (q *sqlQuerier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Org
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
pq.Array(&i.DefaultOrgMemberRoles),
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationByName = `-- name: GetOrganizationByName :one
|
||||
SELECT
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -18405,6 +18407,7 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, arg GetOrganizat
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
pq.Array(&i.DefaultOrgMemberRoles),
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -18475,7 +18478,7 @@ func (q *sqlQuerier) GetOrganizationResourceCountByID(ctx context.Context, organ
|
||||
|
||||
const getOrganizations = `-- name: GetOrganizations :many
|
||||
SELECT
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -18520,6 +18523,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsP
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
pq.Array(&i.DefaultOrgMemberRoles),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -18536,7 +18540,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsP
|
||||
|
||||
const getOrganizationsByUserID = `-- name: GetOrganizationsByUserID :many
|
||||
SELECT
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -18582,6 +18586,7 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, arg GetOrgani
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
pq.Array(&i.DefaultOrgMemberRoles),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -18598,20 +18603,21 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, arg GetOrgani
|
||||
|
||||
const insertOrganization = `-- name: InsertOrganization :one
|
||||
INSERT INTO
|
||||
organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default)
|
||||
organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default, default_org_member_roles)
|
||||
VALUES
|
||||
-- If no organizations exist, and this is the first, make it the default.
|
||||
($1, $2, $3, $4, $5, $6, $7, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
($1, $2, $3, $4, $5, $6, $7, (SELECT TRUE FROM organizations LIMIT 1) IS NULL, $8) RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
|
||||
`
|
||||
|
||||
type InsertOrganizationParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
DefaultOrgMemberRoles []string `db:"default_org_member_roles" json:"default_org_member_roles"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) {
|
||||
@@ -18623,6 +18629,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat
|
||||
arg.Icon,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
pq.Array(arg.DefaultOrgMemberRoles),
|
||||
)
|
||||
var i Organization
|
||||
err := row.Scan(
|
||||
@@ -18636,6 +18643,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
pq.Array(&i.DefaultOrgMemberRoles),
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -18648,19 +18656,21 @@ SET
|
||||
name = $2,
|
||||
display_name = $3,
|
||||
description = $4,
|
||||
icon = $5
|
||||
icon = $5,
|
||||
default_org_member_roles = $6
|
||||
WHERE
|
||||
id = $6
|
||||
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
id = $7
|
||||
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
|
||||
`
|
||||
|
||||
type UpdateOrganizationParams struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
DefaultOrgMemberRoles []string `db:"default_org_member_roles" json:"default_org_member_roles"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error) {
|
||||
@@ -18670,6 +18680,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat
|
||||
arg.DisplayName,
|
||||
arg.Description,
|
||||
arg.Icon,
|
||||
pq.Array(arg.DefaultOrgMemberRoles),
|
||||
arg.ID,
|
||||
)
|
||||
var i Organization
|
||||
@@ -18684,6 +18695,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
pq.Array(&i.DefaultOrgMemberRoles),
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -18716,7 +18728,7 @@ SET
|
||||
updated_at = $2
|
||||
WHERE
|
||||
id = $3
|
||||
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners, default_org_member_roles
|
||||
`
|
||||
|
||||
type UpdateOrganizationWorkspaceSharingSettingsParams struct {
|
||||
@@ -18739,6 +18751,7 @@ func (q *sqlQuerier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Cont
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
pq.Array(&i.DefaultOrgMemberRoles),
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -27898,21 +27911,28 @@ SELECT
|
||||
-- Concatenating the organization id scopes the organization roles.
|
||||
array_agg(org_roles || ':' || organization_members.organization_id::text)
|
||||
FROM
|
||||
organization_members,
|
||||
organization_members
|
||||
JOIN organizations ON organizations.id = organization_members.organization_id,
|
||||
-- All org members get an implied role for their orgs. Most members
|
||||
-- get organization-member, but service accounts will get
|
||||
-- organization-service-account instead. They're largely the same,
|
||||
-- but having them be distinct means we can allow configuring
|
||||
-- service-accounts to have slightly broader permissions–such as
|
||||
-- service-accounts to have slightly broader permissions, such as
|
||||
-- for workspace sharing.
|
||||
--
|
||||
-- organizations.default_org_member_roles is unioned in so changes
|
||||
-- to org defaults propagate to every member on the next request.
|
||||
unnest(
|
||||
array_append(
|
||||
roles,
|
||||
CASE WHEN users.is_service_account THEN
|
||||
'organization-service-account'
|
||||
ELSE
|
||||
'organization-member'
|
||||
END
|
||||
array_cat(
|
||||
array_append(
|
||||
roles,
|
||||
CASE WHEN users.is_service_account THEN
|
||||
'organization-service-account'
|
||||
ELSE
|
||||
'organization-member'
|
||||
END
|
||||
),
|
||||
organizations.default_org_member_roles
|
||||
)
|
||||
) AS org_roles
|
||||
WHERE
|
||||
@@ -27933,7 +27953,7 @@ SELECT
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
id = $1
|
||||
users.id = $1
|
||||
`
|
||||
|
||||
type GetAuthorizationUserRolesRow struct {
|
||||
|
||||
@@ -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 *;
|
||||
|
||||
@@ -609,21 +609,28 @@ SELECT
|
||||
-- Concatenating the organization id scopes the organization roles.
|
||||
array_agg(org_roles || ':' || organization_members.organization_id::text)
|
||||
FROM
|
||||
organization_members,
|
||||
organization_members
|
||||
JOIN organizations ON organizations.id = organization_members.organization_id,
|
||||
-- All org members get an implied role for their orgs. Most members
|
||||
-- get organization-member, but service accounts will get
|
||||
-- organization-service-account instead. They're largely the same,
|
||||
-- but having them be distinct means we can allow configuring
|
||||
-- service-accounts to have slightly broader permissions–such as
|
||||
-- service-accounts to have slightly broader permissions, such as
|
||||
-- for workspace sharing.
|
||||
--
|
||||
-- organizations.default_org_member_roles is unioned in so changes
|
||||
-- to org defaults propagate to every member on the next request.
|
||||
unnest(
|
||||
array_append(
|
||||
roles,
|
||||
CASE WHEN users.is_service_account THEN
|
||||
'organization-service-account'
|
||||
ELSE
|
||||
'organization-member'
|
||||
END
|
||||
array_cat(
|
||||
array_append(
|
||||
roles,
|
||||
CASE WHEN users.is_service_account THEN
|
||||
'organization-service-account'
|
||||
ELSE
|
||||
'organization-member'
|
||||
END
|
||||
),
|
||||
organizations.default_org_member_roles
|
||||
)
|
||||
) AS org_roles
|
||||
WHERE
|
||||
@@ -644,7 +651,7 @@ SELECT
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
id = @user_id;
|
||||
users.id = @user_id;
|
||||
|
||||
-- name: UpdateUserQuietHoursSchedule :one
|
||||
UPDATE
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> |
|
||||
|
||||
Generated
+27
-11
@@ -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",
|
||||
|
||||
Generated
+27
-19
@@ -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
|
||||
|
||||
|
||||
Generated
+18
-11
@@ -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",
|
||||
|
||||
Generated
+4
-4
@@ -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.
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Generated
+13
@@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user