mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: add service_accounts workspace sharing mode (#23093)
Introduce a three-way workspace sharing setting (none, everyone, service_accounts) replacing the boolean workspace_sharing_disabled. In service_accounts mode, only service account-owned workspaces can be shared while regular members' share permissions are removed. Adds a new organization-service-account system role with per-org permissions reconciled alongside the existing organization-member system role. Related to: https://linear.app/codercom/issue/PLAT-28/feat-service-accounts-sharing-mode-and-rbac-role --------- Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com> Co-authored-by: Kayla はな <mckayla@hey.com>
This commit is contained in:
co-authored by
Steven Masley
Kayla はな
parent
6b76e30321
commit
91ec0f1484
@@ -214,7 +214,7 @@ func (r *RootCmd) createOrganizationRole(orgContext *OrganizationContext) *serpe
|
||||
} else {
|
||||
updated, err = client.CreateOrganizationRole(ctx, customRole)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("patch role: %w", err)
|
||||
return xerrors.Errorf("create role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+43
-2
@@ -4819,7 +4819,7 @@ const docTemplate = `{
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
"$ref": "#/definitions/codersdk.UpdateWorkspaceSharingSettingsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -4827,7 +4827,7 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpdateWorkspaceSharingSettingsRequest"
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18720,6 +18720,19 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ShareableWorkspaceOwners": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"everyone",
|
||||
"service_accounts"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ShareableWorkspaceOwnersNone",
|
||||
"ShareableWorkspaceOwnersEveryone",
|
||||
"ShareableWorkspaceOwnersServiceAccounts"
|
||||
]
|
||||
},
|
||||
"codersdk.SharedWorkspaceActor": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20328,7 +20341,21 @@ const docTemplate = `{
|
||||
"codersdk.UpdateWorkspaceSharingSettingsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"shareable_workspace_owners": {
|
||||
"description": "ShareableWorkspaceOwners controls whose workspaces can be shared\nwithin the organization.",
|
||||
"enum": [
|
||||
"none",
|
||||
"everyone",
|
||||
"service_accounts"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.ShareableWorkspaceOwners"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sharing_disabled": {
|
||||
"description": "SharingDisabled is deprecated and left for backward compatibility\npurposes.\nDeprecated: use ` + "`" + `ShareableWorkspaceOwners` + "`" + ` instead",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
@@ -22169,7 +22196,21 @@ const docTemplate = `{
|
||||
"codersdk.WorkspaceSharingSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"shareable_workspace_owners": {
|
||||
"description": "ShareableWorkspaceOwners controls whose workspaces can be shared\nwithin the organization.",
|
||||
"enum": [
|
||||
"none",
|
||||
"everyone",
|
||||
"service_accounts"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.ShareableWorkspaceOwners"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sharing_disabled": {
|
||||
"description": "SharingDisabled is deprecated and left for backward compatibility\npurposes.\nDeprecated: use ` + "`" + `ShareableWorkspaceOwners` + "`" + ` instead",
|
||||
"type": "boolean"
|
||||
},
|
||||
"sharing_globally_disabled": {
|
||||
|
||||
Generated
+31
-2
@@ -4262,7 +4262,7 @@
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
"$ref": "#/definitions/codersdk.UpdateWorkspaceSharingSettingsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -4270,7 +4270,7 @@
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpdateWorkspaceSharingSettingsRequest"
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17109,6 +17109,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ShareableWorkspaceOwners": {
|
||||
"type": "string",
|
||||
"enum": ["none", "everyone", "service_accounts"],
|
||||
"x-enum-varnames": [
|
||||
"ShareableWorkspaceOwnersNone",
|
||||
"ShareableWorkspaceOwnersEveryone",
|
||||
"ShareableWorkspaceOwnersServiceAccounts"
|
||||
]
|
||||
},
|
||||
"codersdk.SharedWorkspaceActor": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18645,7 +18654,17 @@
|
||||
"codersdk.UpdateWorkspaceSharingSettingsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"shareable_workspace_owners": {
|
||||
"description": "ShareableWorkspaceOwners controls whose workspaces can be shared\nwithin the organization.",
|
||||
"enum": ["none", "everyone", "service_accounts"],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.ShareableWorkspaceOwners"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sharing_disabled": {
|
||||
"description": "SharingDisabled is deprecated and left for backward compatibility\npurposes.\nDeprecated: use `ShareableWorkspaceOwners` instead",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
@@ -20384,7 +20403,17 @@
|
||||
"codersdk.WorkspaceSharingSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"shareable_workspace_owners": {
|
||||
"description": "ShareableWorkspaceOwners controls whose workspaces can be shared\nwithin the organization.",
|
||||
"enum": ["none", "everyone", "service_accounts"],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.ShareableWorkspaceOwners"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sharing_disabled": {
|
||||
"description": "SharingDisabled is deprecated and left for backward compatibility\npurposes.\nDeprecated: use `ShareableWorkspaceOwners` instead",
|
||||
"type": "boolean"
|
||||
},
|
||||
"sharing_globally_disabled": {
|
||||
|
||||
@@ -879,6 +879,15 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
|
||||
m(&req)
|
||||
}
|
||||
|
||||
// Service accounts cannot have a password or email and must
|
||||
// use login_type=none. Enforce this after mutators so callers
|
||||
// only need to set ServiceAccount=true.
|
||||
if req.ServiceAccount {
|
||||
req.Password = ""
|
||||
req.Email = ""
|
||||
req.UserLoginType = codersdk.LoginTypeNone
|
||||
}
|
||||
|
||||
user, err := client.CreateUserWithOrgs(context.Background(), req)
|
||||
var apiError *codersdk.Error
|
||||
// If the user already exists by username or email conflict, try again up to "retries" times.
|
||||
|
||||
@@ -1264,7 +1264,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, re
|
||||
// System roles are stored in the database but have a fixed, code-defined
|
||||
// meaning. Do not rewrite the name for them so the static "who can assign
|
||||
// what" mapping applies.
|
||||
if !rbac.SystemRoleName(roleName.Name) {
|
||||
if !rolestore.IsSystemRoleName(roleName.Name) {
|
||||
// To support a dynamic mapping of what roles can assign what, we need
|
||||
// to store this in the database. For now, just use a static role so
|
||||
// owners and org admins can assign roles.
|
||||
@@ -2145,12 +2145,12 @@ func (q *querier) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) erro
|
||||
return fetchAndExec(q.log, q.auth, policy.ActionShare, fetch, q.db.DeleteWorkspaceACLByID)(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
|
||||
func (q *querier) DeleteWorkspaceACLsByOrganization(ctx context.Context, params database.DeleteWorkspaceACLsByOrganizationParams) error {
|
||||
// This is a system-only function.
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.DeleteWorkspaceACLsByOrganization(ctx, organizationID)
|
||||
return q.db.DeleteWorkspaceACLsByOrganization(ctx, params)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error {
|
||||
|
||||
@@ -1485,7 +1485,7 @@ func (s *MethodTestSuite) TestOrganization() {
|
||||
org := testutil.Fake(s.T(), faker, database.Organization{})
|
||||
arg := database.UpdateOrganizationWorkspaceSharingSettingsParams{
|
||||
ID: org.ID,
|
||||
WorkspaceSharingDisabled: true,
|
||||
ShareableWorkspaceOwners: database.ShareableWorkspaceOwnersNone,
|
||||
}
|
||||
dbm.EXPECT().GetOrganizationByID(gomock.Any(), org.ID).Return(org, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateOrganizationWorkspaceSharingSettings(gomock.Any(), arg).Return(org, nil).AnyTimes()
|
||||
@@ -2404,9 +2404,12 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
check.Args(w.ID).Asserts(w, policy.ActionShare)
|
||||
}))
|
||||
s.Run("DeleteWorkspaceACLsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
orgID := uuid.New()
|
||||
dbm.EXPECT().DeleteWorkspaceACLsByOrganization(gomock.Any(), orgID).Return(nil).AnyTimes()
|
||||
check.Args(orgID).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
|
||||
arg := database.DeleteWorkspaceACLsByOrganizationParams{
|
||||
OrganizationID: uuid.New(),
|
||||
ExcludeServiceAccounts: false,
|
||||
}
|
||||
dbm.EXPECT().DeleteWorkspaceACLsByOrganization(gomock.Any(), arg).Return(nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
w := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/rbac/regosql"
|
||||
"github.com/coder/coder/v2/coderd/rbac/rolestore"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
)
|
||||
|
||||
@@ -143,7 +144,7 @@ func (s *MethodTestSuite) Mocked(testCaseF func(dmb *dbmock.MockStore, faker *go
|
||||
UUID: pair.OrganizationID,
|
||||
Valid: pair.OrganizationID != uuid.Nil,
|
||||
},
|
||||
IsSystem: rbac.SystemRoleName(pair.Name),
|
||||
IsSystem: rolestore.IsSystemRoleName(pair.Name),
|
||||
ID: uuid.New(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -650,34 +650,26 @@ func Organization(t testing.TB, db database.Store, orig database.Organization) d
|
||||
})
|
||||
require.NoError(t, err, "insert organization")
|
||||
|
||||
// Populate the placeholder organization-member system role (created by
|
||||
// DB trigger/migration) so org members have expected permissions.
|
||||
//nolint:gocritic // ReconcileOrgMemberRole needs the system:update
|
||||
// Populate the placeholder system roles (created by DB
|
||||
// trigger/migration) so org members have expected permissions.
|
||||
//nolint:gocritic // ReconcileSystemRole needs the system:update
|
||||
// permission that `genCtx` does not have.
|
||||
sysCtx := dbauthz.AsSystemRestricted(genCtx)
|
||||
_, _, err = rolestore.ReconcileOrgMemberRole(sysCtx, db, database.CustomRole{
|
||||
Name: rbac.RoleOrgMember(),
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: org.ID,
|
||||
Valid: true,
|
||||
},
|
||||
}, org.WorkspaceSharingDisabled)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// The trigger that creates the placeholder role didn't run (e.g.,
|
||||
// triggers were disabled in the test). Create the role manually.
|
||||
err = rolestore.CreateOrgMemberRole(sysCtx, db, org)
|
||||
require.NoError(t, err, "create organization-member role")
|
||||
|
||||
_, _, err = rolestore.ReconcileOrgMemberRole(sysCtx, db, database.CustomRole{
|
||||
Name: rbac.RoleOrgMember(),
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: org.ID,
|
||||
Valid: true,
|
||||
},
|
||||
}, org.WorkspaceSharingDisabled)
|
||||
for roleName := range rolestore.SystemRoleNames {
|
||||
role := database.CustomRole{
|
||||
Name: roleName,
|
||||
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
||||
}
|
||||
_, _, err = rolestore.ReconcileSystemRole(sysCtx, db, role, org)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// The trigger that creates the placeholder role didn't run (e.g.,
|
||||
// triggers were disabled in the test). Create the role manually.
|
||||
err = rolestore.CreateSystemRole(sysCtx, db, org, roleName)
|
||||
require.NoError(t, err, "create role "+roleName)
|
||||
_, _, err = rolestore.ReconcileSystemRole(sysCtx, db, role, org)
|
||||
}
|
||||
require.NoError(t, err, "reconcile role "+roleName)
|
||||
}
|
||||
require.NoError(t, err, "reconcile organization-member role")
|
||||
|
||||
return org
|
||||
}
|
||||
|
||||
@@ -696,10 +696,11 @@ func (m queryMetricsStore) DeleteWorkspaceACLByID(ctx context.Context, id uuid.U
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
|
||||
func (m queryMetricsStore) DeleteWorkspaceACLsByOrganization(ctx context.Context, arg database.DeleteWorkspaceACLsByOrganizationParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteWorkspaceACLsByOrganization(ctx, organizationID)
|
||||
r0 := m.s.DeleteWorkspaceACLsByOrganization(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("DeleteWorkspaceACLsByOrganization").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteWorkspaceACLsByOrganization").Inc()
|
||||
return r0
|
||||
}
|
||||
|
||||
@@ -3971,6 +3972,7 @@ func (m queryMetricsStore) UpdateOrganizationWorkspaceSharingSettings(ctx contex
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateOrganizationWorkspaceSharingSettings(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateOrganizationWorkspaceSharingSettings").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateOrganizationWorkspaceSharingSettings").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
|
||||
@@ -1155,17 +1155,17 @@ func (mr *MockStoreMockRecorder) DeleteWorkspaceACLByID(ctx, id any) *gomock.Cal
|
||||
}
|
||||
|
||||
// DeleteWorkspaceACLsByOrganization mocks base method.
|
||||
func (m *MockStore) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
|
||||
func (m *MockStore) DeleteWorkspaceACLsByOrganization(ctx context.Context, arg database.DeleteWorkspaceACLsByOrganizationParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteWorkspaceACLsByOrganization", ctx, organizationID)
|
||||
ret := m.ctrl.Call(m, "DeleteWorkspaceACLsByOrganization", ctx, arg)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteWorkspaceACLsByOrganization indicates an expected call of DeleteWorkspaceACLsByOrganization.
|
||||
func (mr *MockStoreMockRecorder) DeleteWorkspaceACLsByOrganization(ctx, organizationID any) *gomock.Call {
|
||||
func (mr *MockStoreMockRecorder) DeleteWorkspaceACLsByOrganization(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceACLsByOrganization", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceACLsByOrganization), ctx, organizationID)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceACLsByOrganization", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceACLsByOrganization), ctx, arg)
|
||||
}
|
||||
|
||||
// DeleteWorkspaceAgentPortShare mocks base method.
|
||||
|
||||
Generated
+25
-4
@@ -512,6 +512,12 @@ CREATE TYPE resource_type AS ENUM (
|
||||
'ai_seat'
|
||||
);
|
||||
|
||||
CREATE TYPE shareable_workspace_owners AS ENUM (
|
||||
'none',
|
||||
'everyone',
|
||||
'service_accounts'
|
||||
);
|
||||
|
||||
CREATE TYPE startup_script_behavior AS ENUM (
|
||||
'blocking',
|
||||
'non-blocking'
|
||||
@@ -792,7 +798,7 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION insert_org_member_system_role() RETURNS trigger
|
||||
CREATE FUNCTION insert_organization_system_roles() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
@@ -807,7 +813,8 @@ BEGIN
|
||||
is_system,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
) VALUES
|
||||
(
|
||||
'organization-member',
|
||||
'',
|
||||
NEW.id,
|
||||
@@ -818,6 +825,18 @@ BEGIN
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
(
|
||||
'organization-service-account',
|
||||
'',
|
||||
NEW.id,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
@@ -1832,9 +1851,11 @@ CREATE TABLE organizations (
|
||||
display_name text NOT NULL,
|
||||
icon text DEFAULT ''::text NOT NULL,
|
||||
deleted boolean DEFAULT false NOT NULL,
|
||||
workspace_sharing_disabled boolean DEFAULT false NOT NULL
|
||||
shareable_workspace_owners shareable_workspace_owners DEFAULT 'everyone'::shareable_workspace_owners NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN organizations.shareable_workspace_owners IS 'Controls whose workspaces can be shared: none, everyone, or service_accounts.';
|
||||
|
||||
CREATE TABLE parameter_schemas (
|
||||
id uuid NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
@@ -3863,7 +3884,7 @@ CREATE TRIGGER trigger_delete_oauth2_provider_app_token AFTER DELETE ON oauth2_p
|
||||
|
||||
CREATE TRIGGER trigger_insert_apikeys BEFORE INSERT ON api_keys FOR EACH ROW EXECUTE FUNCTION insert_apikey_fail_if_user_deleted();
|
||||
|
||||
CREATE TRIGGER trigger_insert_org_member_system_role AFTER INSERT ON organizations FOR EACH ROW EXECUTE FUNCTION insert_org_member_system_role();
|
||||
CREATE TRIGGER trigger_insert_organization_system_roles AFTER INSERT ON organizations FOR EACH ROW EXECUTE FUNCTION insert_organization_system_roles();
|
||||
|
||||
CREATE TRIGGER trigger_nullify_next_start_at_on_workspace_autostart_modificati AFTER UPDATE ON workspaces FOR EACH ROW EXECUTE FUNCTION nullify_next_start_at_on_workspace_autostart_modification();
|
||||
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
DELETE FROM custom_roles
|
||||
WHERE name = 'organization-service-account' AND is_system = true;
|
||||
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN workspace_sharing_disabled boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Migrate back: 'none' -> disabled, everything else -> enabled.
|
||||
UPDATE organizations
|
||||
SET workspace_sharing_disabled = true
|
||||
WHERE shareable_workspace_owners = 'none';
|
||||
|
||||
ALTER TABLE organizations DROP COLUMN shareable_workspace_owners;
|
||||
|
||||
DROP TYPE shareable_workspace_owners;
|
||||
|
||||
-- Restore the original single-role trigger from migration 408.
|
||||
DROP TRIGGER IF EXISTS trigger_insert_organization_system_roles ON organizations;
|
||||
DROP FUNCTION IF EXISTS insert_organization_system_roles;
|
||||
|
||||
CREATE OR REPLACE FUNCTION insert_org_member_system_role() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
INSERT INTO custom_roles (
|
||||
name,
|
||||
display_name,
|
||||
organization_id,
|
||||
site_permissions,
|
||||
org_permissions,
|
||||
user_permissions,
|
||||
member_permissions,
|
||||
is_system,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'organization-member',
|
||||
'',
|
||||
NEW.id,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_insert_org_member_system_role
|
||||
AFTER INSERT ON organizations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION insert_org_member_system_role();
|
||||
@@ -0,0 +1,101 @@
|
||||
CREATE TYPE shareable_workspace_owners AS ENUM ('none', 'everyone', 'service_accounts');
|
||||
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN shareable_workspace_owners shareable_workspace_owners NOT NULL DEFAULT 'everyone';
|
||||
|
||||
COMMENT ON COLUMN organizations.shareable_workspace_owners IS 'Controls whose workspaces can be shared: none, everyone, or service_accounts.';
|
||||
|
||||
-- Migrate existing data from the boolean column.
|
||||
UPDATE organizations
|
||||
SET shareable_workspace_owners = 'none'
|
||||
WHERE workspace_sharing_disabled = true;
|
||||
|
||||
ALTER TABLE organizations DROP COLUMN workspace_sharing_disabled;
|
||||
|
||||
-- Defensively rename any existing 'organization-service-account' roles
|
||||
-- so they don't collide with the new system role.
|
||||
UPDATE custom_roles
|
||||
SET name = name || '-' || id::text
|
||||
-- lower(name) is part of the existing unique index
|
||||
WHERE lower(name) = 'organization-service-account';
|
||||
|
||||
-- Create skeleton organization-service-account system roles for all
|
||||
-- existing organizations, mirroring what migration 408 did for
|
||||
-- organization-member.
|
||||
INSERT INTO custom_roles (
|
||||
name,
|
||||
display_name,
|
||||
organization_id,
|
||||
site_permissions,
|
||||
org_permissions,
|
||||
user_permissions,
|
||||
member_permissions,
|
||||
is_system,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
'organization-service-account',
|
||||
'',
|
||||
id,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM
|
||||
organizations;
|
||||
|
||||
-- Replace the single-role trigger with one that creates both system
|
||||
-- roles when a new organization is inserted.
|
||||
DROP TRIGGER IF EXISTS trigger_insert_org_member_system_role ON organizations;
|
||||
DROP FUNCTION IF EXISTS insert_org_member_system_role;
|
||||
|
||||
CREATE OR REPLACE FUNCTION insert_organization_system_roles() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
INSERT INTO custom_roles (
|
||||
name,
|
||||
display_name,
|
||||
organization_id,
|
||||
site_permissions,
|
||||
org_permissions,
|
||||
user_permissions,
|
||||
member_permissions,
|
||||
is_system,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES
|
||||
(
|
||||
'organization-member',
|
||||
'',
|
||||
NEW.id,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
(
|
||||
'organization-service-account',
|
||||
'',
|
||||
NEW.id,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_insert_organization_system_roles
|
||||
AFTER INSERT ON organizations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION insert_organization_system_roles();
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
-- Fixture for migration 000443_three_options_for_allowed_workspace_sharing.
|
||||
-- Inserts a custom role named 'Organization-Service-Account' (mixed case)
|
||||
-- to ensure the migration's case-insensitive rename catches it.
|
||||
INSERT INTO custom_roles (
|
||||
name,
|
||||
display_name,
|
||||
organization_id,
|
||||
site_permissions,
|
||||
org_permissions,
|
||||
user_permissions,
|
||||
member_permissions,
|
||||
is_system,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
'Organization-Service-Account',
|
||||
'User-created role',
|
||||
'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1',
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
false,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
+72
-10
@@ -3131,6 +3131,67 @@ func AllResourceTypeValues() []ResourceType {
|
||||
}
|
||||
}
|
||||
|
||||
type ShareableWorkspaceOwners string
|
||||
|
||||
const (
|
||||
ShareableWorkspaceOwnersNone ShareableWorkspaceOwners = "none"
|
||||
ShareableWorkspaceOwnersEveryone ShareableWorkspaceOwners = "everyone"
|
||||
ShareableWorkspaceOwnersServiceAccounts ShareableWorkspaceOwners = "service_accounts"
|
||||
)
|
||||
|
||||
func (e *ShareableWorkspaceOwners) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = ShareableWorkspaceOwners(s)
|
||||
case string:
|
||||
*e = ShareableWorkspaceOwners(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for ShareableWorkspaceOwners: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullShareableWorkspaceOwners struct {
|
||||
ShareableWorkspaceOwners ShareableWorkspaceOwners `json:"shareable_workspace_owners"`
|
||||
Valid bool `json:"valid"` // Valid is true if ShareableWorkspaceOwners is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullShareableWorkspaceOwners) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.ShareableWorkspaceOwners, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.ShareableWorkspaceOwners.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullShareableWorkspaceOwners) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.ShareableWorkspaceOwners), nil
|
||||
}
|
||||
|
||||
func (e ShareableWorkspaceOwners) Valid() bool {
|
||||
switch e {
|
||||
case ShareableWorkspaceOwnersNone,
|
||||
ShareableWorkspaceOwnersEveryone,
|
||||
ShareableWorkspaceOwnersServiceAccounts:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AllShareableWorkspaceOwnersValues() []ShareableWorkspaceOwners {
|
||||
return []ShareableWorkspaceOwners{
|
||||
ShareableWorkspaceOwnersNone,
|
||||
ShareableWorkspaceOwnersEveryone,
|
||||
ShareableWorkspaceOwnersServiceAccounts,
|
||||
}
|
||||
}
|
||||
|
||||
type StartupScriptBehavior string
|
||||
|
||||
const (
|
||||
@@ -4535,16 +4596,17 @@ type OAuth2ProviderAppToken struct {
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
IsDefault bool `db:"is_default" json:"is_default"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Deleted bool `db:"deleted" json:"deleted"`
|
||||
WorkspaceSharingDisabled bool `db:"workspace_sharing_disabled" json:"workspace_sharing_disabled"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
IsDefault bool `db:"is_default" json:"is_default"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type OrganizationMember struct {
|
||||
|
||||
@@ -150,7 +150,7 @@ type sqlcQuerier interface {
|
||||
DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error
|
||||
DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error
|
||||
DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error
|
||||
DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error
|
||||
DeleteWorkspaceACLsByOrganization(ctx context.Context, arg DeleteWorkspaceACLsByOrganizationParams) error
|
||||
DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error
|
||||
DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error
|
||||
DeleteWorkspaceSubAgentByID(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
+167
-58
@@ -2655,6 +2655,42 @@ func TestDeleteCustomRoleDoesNotDeleteSystemRole(t *testing.T) {
|
||||
require.True(t, roles[0].IsSystem)
|
||||
}
|
||||
|
||||
func TestGetAuthorizationUserRolesImpliedOrgRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
|
||||
regularUser := 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: regularUser.ID,
|
||||
})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org.ID,
|
||||
UserID: saUser.ID,
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
wantMember := rbac.RoleOrgMember() + ":" + org.ID.String()
|
||||
wantSA := rbac.RoleOrgServiceAccount() + ":" + org.ID.String()
|
||||
|
||||
// Regular users get the implied organization-member role.
|
||||
regularRoles, err := db.GetAuthorizationUserRoles(ctx, regularUser.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, regularRoles.Roles, wantMember)
|
||||
require.NotContains(t, regularRoles.Roles, wantSA)
|
||||
|
||||
// Service accounts get the implied organization-service-account role.
|
||||
saRoles, err := db.GetAuthorizationUserRoles(ctx, saUser.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, saRoles.Roles, wantSA)
|
||||
require.NotContains(t, saRoles.Roles, wantMember)
|
||||
}
|
||||
|
||||
func TestUpdateOrganizationWorkspaceSharingSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2665,82 +2701,155 @@ func TestUpdateOrganizationWorkspaceSharingSettings(t *testing.T) {
|
||||
|
||||
updated, err := db.UpdateOrganizationWorkspaceSharingSettings(ctx, database.UpdateOrganizationWorkspaceSharingSettingsParams{
|
||||
ID: org.ID,
|
||||
WorkspaceSharingDisabled: true,
|
||||
ShareableWorkspaceOwners: database.ShareableWorkspaceOwnersNone,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, updated.WorkspaceSharingDisabled)
|
||||
require.Equal(t, database.ShareableWorkspaceOwnersNone, updated.ShareableWorkspaceOwners)
|
||||
|
||||
got, err := db.GetOrganizationByID(ctx, org.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.WorkspaceSharingDisabled)
|
||||
require.Equal(t, database.ShareableWorkspaceOwnersNone, got.ShareableWorkspaceOwners)
|
||||
}
|
||||
|
||||
func TestDeleteWorkspaceACLsByOrganization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org1 := dbgen.Organization(t, db, database.Organization{})
|
||||
org2 := dbgen.Organization(t, db, database.Organization{})
|
||||
t.Run("DeletesAll", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner1 := dbgen.User(t, db, database.User{})
|
||||
owner2 := dbgen.User(t, db, database.User{})
|
||||
sharedUser := dbgen.User(t, db, database.User{})
|
||||
sharedGroup := dbgen.Group(t, db, database.Group{
|
||||
OrganizationID: org1.ID,
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org1 := dbgen.Organization(t, db, database.Organization{})
|
||||
org2 := dbgen.Organization(t, db, database.Organization{})
|
||||
|
||||
owner1 := dbgen.User(t, db, database.User{})
|
||||
owner2 := dbgen.User(t, db, database.User{})
|
||||
sharedUser := dbgen.User(t, db, database.User{})
|
||||
sharedGroup := dbgen.Group(t, db, database.Group{
|
||||
OrganizationID: org1.ID,
|
||||
})
|
||||
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org1.ID,
|
||||
UserID: owner1.ID,
|
||||
})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org2.ID,
|
||||
UserID: owner2.ID,
|
||||
})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org1.ID,
|
||||
UserID: sharedUser.ID,
|
||||
})
|
||||
|
||||
ws1 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: owner1.ID,
|
||||
OrganizationID: org1.ID,
|
||||
UserACL: database.WorkspaceACL{
|
||||
sharedUser.ID.String(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
GroupACL: database.WorkspaceACL{
|
||||
sharedGroup.ID.String(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
}).Do().Workspace
|
||||
|
||||
ws2 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: owner2.ID,
|
||||
OrganizationID: org2.ID,
|
||||
UserACL: database.WorkspaceACL{
|
||||
uuid.NewString(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
}).Do().Workspace
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
err := db.DeleteWorkspaceACLsByOrganization(ctx, database.DeleteWorkspaceACLsByOrganizationParams{
|
||||
OrganizationID: org1.ID,
|
||||
ExcludeServiceAccounts: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got1, err := db.GetWorkspaceByID(ctx, ws1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got1.UserACL)
|
||||
require.Empty(t, got1.GroupACL)
|
||||
|
||||
got2, err := db.GetWorkspaceByID(ctx, ws2.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, got2.UserACL)
|
||||
})
|
||||
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org1.ID,
|
||||
UserID: owner1.ID,
|
||||
})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org2.ID,
|
||||
UserID: owner2.ID,
|
||||
})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org1.ID,
|
||||
UserID: sharedUser.ID,
|
||||
})
|
||||
t.Run("ExcludesServiceAccounts", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ws1 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: owner1.ID,
|
||||
OrganizationID: org1.ID,
|
||||
UserACL: database.WorkspaceACL{
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
|
||||
regularUser := dbgen.User(t, db, database.User{})
|
||||
saUser := dbgen.User(t, db, database.User{IsServiceAccount: true})
|
||||
sharedUser := dbgen.User(t, db, database.User{})
|
||||
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org.ID,
|
||||
UserID: regularUser.ID,
|
||||
})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org.ID,
|
||||
UserID: saUser.ID,
|
||||
})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org.ID,
|
||||
UserID: sharedUser.ID,
|
||||
})
|
||||
|
||||
regularWS := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: regularUser.ID,
|
||||
OrganizationID: org.ID,
|
||||
UserACL: database.WorkspaceACL{
|
||||
sharedUser.ID.String(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
}).Do().Workspace
|
||||
|
||||
saWS := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: saUser.ID,
|
||||
OrganizationID: org.ID,
|
||||
UserACL: database.WorkspaceACL{
|
||||
sharedUser.ID.String(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
}).Do().Workspace
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
err := db.DeleteWorkspaceACLsByOrganization(ctx, database.DeleteWorkspaceACLsByOrganizationParams{
|
||||
OrganizationID: org.ID,
|
||||
ExcludeServiceAccounts: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Regular user workspace ACLs should be cleared.
|
||||
gotRegular, err := db.GetWorkspaceByID(ctx, regularWS.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, gotRegular.UserACL)
|
||||
|
||||
// Service account workspace ACLs should be preserved.
|
||||
gotSA, err := db.GetWorkspaceByID(ctx, saWS.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.WorkspaceACL{
|
||||
sharedUser.ID.String(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
GroupACL: database.WorkspaceACL{
|
||||
sharedGroup.ID.String(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
}).Do().Workspace
|
||||
|
||||
ws2 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: owner2.ID,
|
||||
OrganizationID: org2.ID,
|
||||
UserACL: database.WorkspaceACL{
|
||||
uuid.NewString(): {
|
||||
Permissions: []policy.Action{policy.ActionRead},
|
||||
},
|
||||
},
|
||||
}).Do().Workspace
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
err := db.DeleteWorkspaceACLsByOrganization(ctx, org1.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
got1, err := db.GetWorkspaceByID(ctx, ws1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got1.UserACL)
|
||||
require.Empty(t, got1.GroupACL)
|
||||
|
||||
got2, err := db.GetWorkspaceByID(ctx, ws2.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, got2.UserACL)
|
||||
}, gotSA.UserACL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorizedAuditLogs(t *testing.T) {
|
||||
|
||||
@@ -11035,7 +11035,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, workspace_sharing_disabled
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -11057,14 +11057,14 @@ func (q *sqlQuerier) GetDefaultOrganization(ctx context.Context) (Organization,
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.WorkspaceSharingDisabled,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationByID = `-- name: GetOrganizationByID :one
|
||||
SELECT
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -11084,14 +11084,14 @@ func (q *sqlQuerier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Org
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.WorkspaceSharingDisabled,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationByName = `-- name: GetOrganizationByName :one
|
||||
SELECT
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -11120,7 +11120,7 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, arg GetOrganizat
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.WorkspaceSharingDisabled,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -11191,7 +11191,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, workspace_sharing_disabled
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -11235,7 +11235,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsP
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.WorkspaceSharingDisabled,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -11252,7 +11252,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, workspace_sharing_disabled
|
||||
id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
@@ -11297,7 +11297,7 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, arg GetOrgani
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.WorkspaceSharingDisabled,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -11317,7 +11317,7 @@ INSERT INTO
|
||||
organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default)
|
||||
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, workspace_sharing_disabled
|
||||
($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
|
||||
`
|
||||
|
||||
type InsertOrganizationParams struct {
|
||||
@@ -11351,7 +11351,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.WorkspaceSharingDisabled,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -11367,7 +11367,7 @@ SET
|
||||
icon = $5
|
||||
WHERE
|
||||
id = $6
|
||||
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled
|
||||
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
`
|
||||
|
||||
type UpdateOrganizationParams struct {
|
||||
@@ -11399,7 +11399,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.WorkspaceSharingDisabled,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -11428,21 +11428,21 @@ const updateOrganizationWorkspaceSharingSettings = `-- name: UpdateOrganizationW
|
||||
UPDATE
|
||||
organizations
|
||||
SET
|
||||
workspace_sharing_disabled = $1,
|
||||
shareable_workspace_owners = $1,
|
||||
updated_at = $2
|
||||
WHERE
|
||||
id = $3
|
||||
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled
|
||||
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, shareable_workspace_owners
|
||||
`
|
||||
|
||||
type UpdateOrganizationWorkspaceSharingSettingsParams struct {
|
||||
WorkspaceSharingDisabled bool `db:"workspace_sharing_disabled" json:"workspace_sharing_disabled"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ShareableWorkspaceOwners ShareableWorkspaceOwners `db:"shareable_workspace_owners" json:"shareable_workspace_owners"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg UpdateOrganizationWorkspaceSharingSettingsParams) (Organization, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateOrganizationWorkspaceSharingSettings, arg.WorkspaceSharingDisabled, arg.UpdatedAt, arg.ID)
|
||||
row := q.db.QueryRowContext(ctx, updateOrganizationWorkspaceSharingSettings, arg.ShareableWorkspaceOwners, arg.UpdatedAt, arg.ID)
|
||||
var i Organization
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
@@ -11454,7 +11454,7 @@ func (q *sqlQuerier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Cont
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Deleted,
|
||||
&i.WorkspaceSharingDisabled,
|
||||
&i.ShareableWorkspaceOwners,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -19373,9 +19373,21 @@ SELECT
|
||||
array_agg(org_roles || ':' || organization_members.organization_id::text)
|
||||
FROM
|
||||
organization_members,
|
||||
-- All org_members get the organization-member role for their orgs
|
||||
-- All org members get an implied role for their orgs. Most members
|
||||
-- get organization-member, but service accounts will get
|
||||
-- organization-service-account instead. They're largely the same,
|
||||
-- but having them be distinct means we can allow configuring
|
||||
-- service-accounts to have slightly broader permissions–such as
|
||||
-- for workspace sharing.
|
||||
unnest(
|
||||
array_append(roles, 'organization-member')
|
||||
array_append(
|
||||
roles,
|
||||
CASE WHEN users.is_service_account THEN
|
||||
'organization-service-account'
|
||||
ELSE
|
||||
'organization-member'
|
||||
END
|
||||
)
|
||||
) AS org_roles
|
||||
WHERE
|
||||
user_id = users.id
|
||||
@@ -25466,10 +25478,21 @@ SET
|
||||
user_acl = '{}'::jsonb
|
||||
WHERE
|
||||
organization_id = $1
|
||||
AND (
|
||||
NOT $2::boolean
|
||||
OR owner_id NOT IN (
|
||||
SELECT id FROM users WHERE is_service_account = true
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteWorkspaceACLsByOrganization, organizationID)
|
||||
type DeleteWorkspaceACLsByOrganizationParams struct {
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
ExcludeServiceAccounts bool `db:"exclude_service_accounts" json:"exclude_service_accounts"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) DeleteWorkspaceACLsByOrganization(ctx context.Context, arg DeleteWorkspaceACLsByOrganizationParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteWorkspaceACLsByOrganization, arg.OrganizationID, arg.ExcludeServiceAccounts)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ WHERE
|
||||
UPDATE
|
||||
organizations
|
||||
SET
|
||||
workspace_sharing_disabled = @workspace_sharing_disabled,
|
||||
shareable_workspace_owners = @shareable_workspace_owners,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
id = @id
|
||||
|
||||
@@ -391,9 +391,21 @@ SELECT
|
||||
array_agg(org_roles || ':' || organization_members.organization_id::text)
|
||||
FROM
|
||||
organization_members,
|
||||
-- All org_members get the organization-member role for their orgs
|
||||
-- All org members get an implied role for their orgs. Most members
|
||||
-- get organization-member, but service accounts will get
|
||||
-- organization-service-account instead. They're largely the same,
|
||||
-- but having them be distinct means we can allow configuring
|
||||
-- service-accounts to have slightly broader permissions–such as
|
||||
-- for workspace sharing.
|
||||
unnest(
|
||||
array_append(roles, 'organization-member')
|
||||
array_append(
|
||||
roles,
|
||||
CASE WHEN users.is_service_account THEN
|
||||
'organization-service-account'
|
||||
ELSE
|
||||
'organization-member'
|
||||
END
|
||||
)
|
||||
) AS org_roles
|
||||
WHERE
|
||||
user_id = users.id
|
||||
|
||||
@@ -955,7 +955,13 @@ SET
|
||||
group_acl = '{}'::jsonb,
|
||||
user_acl = '{}'::jsonb
|
||||
WHERE
|
||||
organization_id = @organization_id;
|
||||
organization_id = @organization_id
|
||||
AND (
|
||||
NOT @exclude_service_accounts::boolean
|
||||
OR owner_id NOT IN (
|
||||
SELECT id FROM users WHERE is_service_account = true
|
||||
)
|
||||
);
|
||||
|
||||
-- name: GetRegularWorkspaceCreateMetrics :many
|
||||
-- Count regular workspaces: only those whose first successful 'start' build
|
||||
|
||||
@@ -1404,8 +1404,8 @@ func testAuthorize(t *testing.T, name string, subject Subject, sets ...[]authTes
|
||||
// RoleByName won't resolve it here. Assume the default behavior: workspace
|
||||
// sharing enabled.
|
||||
func orgMemberRole(orgID uuid.UUID) Role {
|
||||
workspaceSharingDisabled := false
|
||||
orgPerms, memberPerms := OrgMemberPermissions(workspaceSharingDisabled)
|
||||
settings := OrgSettings{ShareableWorkspaceOwners: ShareableWorkspaceOwnersEveryone}
|
||||
perms := OrgMemberPermissions(settings)
|
||||
return Role{
|
||||
Identifier: ScopedRoleOrgMember(orgID),
|
||||
DisplayName: "",
|
||||
@@ -1413,8 +1413,8 @@ func orgMemberRole(orgID uuid.UUID) Role {
|
||||
User: []Permission{},
|
||||
ByOrgID: map[string]OrgPermissions{
|
||||
orgID.String(): {
|
||||
Org: orgPerms,
|
||||
Member: memberPerms,
|
||||
Org: perms.Org,
|
||||
Member: perms.Member,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+124
-53
@@ -29,6 +29,7 @@ const (
|
||||
|
||||
orgAdmin string = "organization-admin"
|
||||
orgMember string = "organization-member"
|
||||
orgServiceAccount string = "organization-service-account"
|
||||
orgAuditor string = "organization-auditor"
|
||||
orgUserAdmin string = "organization-user-admin"
|
||||
orgTemplateAdmin string = "organization-template-admin"
|
||||
@@ -150,6 +151,10 @@ func RoleOrgMember() string {
|
||||
return orgMember
|
||||
}
|
||||
|
||||
func RoleOrgServiceAccount() string {
|
||||
return orgServiceAccount
|
||||
}
|
||||
|
||||
func RoleOrgAuditor() string {
|
||||
return orgAuditor
|
||||
}
|
||||
@@ -229,31 +234,16 @@ func allPermsExcept(excepts ...Objecter) []Permission {
|
||||
// https://github.com/coder/coder/issues/1194
|
||||
var builtInRoles map[string]func(orgID uuid.UUID) Role
|
||||
|
||||
// systemRoles are roles that have migrated from builtInRoles to
|
||||
// database storage. This migration is partial - permissions are still
|
||||
// generated at runtime and reconciled to the database, rather than
|
||||
// the database being the source of truth.
|
||||
var systemRoles = map[string]struct{}{
|
||||
RoleOrgMember(): {},
|
||||
}
|
||||
|
||||
func SystemRoleName(name string) bool {
|
||||
_, ok := systemRoles[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
type RoleOptions struct {
|
||||
NoOwnerWorkspaceExec bool
|
||||
NoWorkspaceSharing bool
|
||||
}
|
||||
|
||||
// ReservedRoleName exists because the database should only allow unique role
|
||||
// names, but some roles are built in or generated at runtime. So these names
|
||||
// are reserved
|
||||
// names, but some roles are built in. So these names are reserved
|
||||
func ReservedRoleName(name string) bool {
|
||||
_, isBuiltIn := builtInRoles[name]
|
||||
_, isSystem := systemRoles[name]
|
||||
return isBuiltIn || isSystem
|
||||
_, ok := builtInRoles[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ReloadBuiltinRoles loads the static roles into the builtInRoles map.
|
||||
@@ -938,21 +928,32 @@ func PermissionsEqual(a, b []Permission) bool {
|
||||
return len(setA) == len(setB)
|
||||
}
|
||||
|
||||
// OrgSettings carries organization-level settings that affect system
|
||||
// role permissions. It lives in the rbac package to avoid a cyclic
|
||||
// dependency with the database package. Callers in rolestore map
|
||||
// database.Organization fields onto this struct.
|
||||
type OrgSettings struct {
|
||||
ShareableWorkspaceOwners ShareableWorkspaceOwners
|
||||
}
|
||||
type ShareableWorkspaceOwners string
|
||||
|
||||
const (
|
||||
ShareableWorkspaceOwnersNone ShareableWorkspaceOwners = "none"
|
||||
ShareableWorkspaceOwnersEveryone ShareableWorkspaceOwners = "everyone"
|
||||
ShareableWorkspaceOwnersServiceAccounts ShareableWorkspaceOwners = "service_accounts"
|
||||
)
|
||||
|
||||
// OrgRolePermissions holds the two permission sets that make up a
|
||||
// system role: org-wide permissions and member-scoped permissions.
|
||||
type OrgRolePermissions struct {
|
||||
Org []Permission
|
||||
Member []Permission
|
||||
}
|
||||
|
||||
// OrgMemberPermissions returns the permissions for the organization-member
|
||||
// system role. The results are then stored in the database and can vary per
|
||||
// organization based on the workspace_sharing_disabled setting.
|
||||
// This is the source of truth for org-member permissions, used by:
|
||||
// - the startup reconciliation routine, to keep permissions current with
|
||||
// RBAC resources
|
||||
// - the organization workspace sharing setting endpoint, when updating
|
||||
// the setting
|
||||
// - the org creation endpoint, when populating the organization-member
|
||||
// system role created by the DB trigger
|
||||
//
|
||||
//nolint:revive // workspaceSharingDisabled is an org setting
|
||||
func OrgMemberPermissions(workspaceSharingDisabled bool) (
|
||||
orgPerms, memberPerms []Permission,
|
||||
) {
|
||||
// system role, which can vary based on the organization's workspace sharing
|
||||
// settings.
|
||||
func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
|
||||
// Organization-level permissions that all org members get.
|
||||
orgPermMap := map[string][]policy.Action{
|
||||
// All users can see provisioner daemons for workspace creation.
|
||||
@@ -963,18 +964,35 @@ func OrgMemberPermissions(workspaceSharingDisabled bool) (
|
||||
ResourceAssignOrgRole.Type: {policy.ActionRead},
|
||||
}
|
||||
|
||||
// When workspace sharing is enabled, members need to see other org members
|
||||
// and groups to share workspaces with them.
|
||||
if !workspaceSharingDisabled {
|
||||
// In all modes of workspace sharing but `none`, members need to
|
||||
// see other org members (including service accounts) to either
|
||||
// share with them or get access to their shared workspaces,
|
||||
// resolved through GET /users/{user}/workspace/{workspace}
|
||||
if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersNone {
|
||||
orgPermMap[ResourceOrganizationMember.Type] = []policy.Action{policy.ActionRead}
|
||||
}
|
||||
|
||||
// When workspace sharing is open to members, they also need to
|
||||
// see org groups to share with them.
|
||||
if org.ShareableWorkspaceOwners == ShareableWorkspaceOwnersEveryone {
|
||||
orgPermMap[ResourceGroup.Type] = []policy.Action{policy.ActionRead}
|
||||
}
|
||||
|
||||
orgPerms = Permissions(orgPermMap)
|
||||
orgPerms := Permissions(orgPermMap)
|
||||
|
||||
if org.ShareableWorkspaceOwners == ShareableWorkspaceOwnersNone {
|
||||
// Org-level negation blocks sharing on ANY workspace in the
|
||||
// org. This overrides any positive permission from other
|
||||
// roles, including org-admin.
|
||||
orgPerms = append(orgPerms, Permission{
|
||||
Negate: true,
|
||||
ResourceType: ResourceWorkspace.Type,
|
||||
Action: policy.ActionShare,
|
||||
})
|
||||
}
|
||||
|
||||
// Member-scoped permissions (resources owned by the member).
|
||||
// Uses allPermsExcept to automatically include permissions for new resources.
|
||||
memberPerms = append(
|
||||
memberPerms := append(
|
||||
allPermsExcept(
|
||||
ResourceWorkspaceDormant,
|
||||
ResourcePrebuiltWorkspace,
|
||||
@@ -998,24 +1016,47 @@ func OrgMemberPermissions(workspaceSharingDisabled bool) (
|
||||
ResourceOrganizationMember.Type: {
|
||||
policy.ActionRead,
|
||||
},
|
||||
// Users can create provisioner daemons scoped to themselves.
|
||||
//
|
||||
// TODO(geokat): copied from the original built-in role
|
||||
// verbatim, but seems to be a no-op (not excepted above;
|
||||
// plus no owner is set for the ProvisionerDaemon RBAC
|
||||
// object).
|
||||
ResourceProvisionerDaemon.Type: {
|
||||
policy.ActionRead,
|
||||
policy.ActionCreate,
|
||||
policy.ActionUpdate,
|
||||
},
|
||||
})...,
|
||||
)
|
||||
|
||||
if workspaceSharingDisabled {
|
||||
if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersEveryone {
|
||||
memberPerms = append(memberPerms, Permission{
|
||||
Negate: true,
|
||||
ResourceType: ResourceWorkspace.Type,
|
||||
Action: policy.ActionShare,
|
||||
})
|
||||
}
|
||||
|
||||
return OrgRolePermissions{Org: orgPerms, Member: memberPerms}
|
||||
}
|
||||
|
||||
// OrgServiceAccountPermissions returns the permissions for the
|
||||
// organization-service-account system role, which can vary based on
|
||||
// the organization's workspace sharing settings.
|
||||
func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
|
||||
// Organization-level permissions that all org service accounts get.
|
||||
orgPermMap := map[string][]policy.Action{
|
||||
// All users can see provisioner daemons for workspace creation.
|
||||
ResourceProvisionerDaemon.Type: {policy.ActionRead},
|
||||
// All org members can read the organization.
|
||||
ResourceOrganization.Type: {policy.ActionRead},
|
||||
// Can read available roles.
|
||||
ResourceAssignOrgRole.Type: {policy.ActionRead},
|
||||
}
|
||||
|
||||
// When workspace sharing is enabled, service accounts need to see
|
||||
// other org members and groups to share workspaces with them.
|
||||
if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersNone {
|
||||
orgPermMap[ResourceOrganizationMember.Type] = []policy.Action{policy.ActionRead}
|
||||
orgPermMap[ResourceGroup.Type] = []policy.Action{policy.ActionRead}
|
||||
}
|
||||
|
||||
orgPerms := Permissions(orgPermMap)
|
||||
|
||||
if org.ShareableWorkspaceOwners == ShareableWorkspaceOwnersNone {
|
||||
// Org-level negation blocks sharing on ANY workspace in the
|
||||
// org. This overrides any positive permission from other
|
||||
// roles, including org-admin.
|
||||
// org. If a service account has any other roles assigned,
|
||||
// this negation will override any positive perms in them, too.
|
||||
orgPerms = append(orgPerms, Permission{
|
||||
Negate: true,
|
||||
ResourceType: ResourceWorkspace.Type,
|
||||
@@ -1023,5 +1064,35 @@ func OrgMemberPermissions(workspaceSharingDisabled bool) (
|
||||
})
|
||||
}
|
||||
|
||||
return orgPerms, memberPerms
|
||||
// service account-scoped permissions (resources owned by the
|
||||
// service account). Uses allPermsExcept to automatically include
|
||||
// permissions for new resources.
|
||||
memberPerms := append(
|
||||
allPermsExcept(
|
||||
ResourceWorkspaceDormant,
|
||||
ResourcePrebuiltWorkspace,
|
||||
ResourceUser,
|
||||
ResourceOrganizationMember,
|
||||
),
|
||||
Permissions(map[string][]policy.Action{
|
||||
// Reduced permission set on dormant workspaces. No build,
|
||||
// ssh, or exec.
|
||||
ResourceWorkspaceDormant.Type: {
|
||||
policy.ActionRead,
|
||||
policy.ActionDelete,
|
||||
policy.ActionCreate,
|
||||
policy.ActionUpdate,
|
||||
policy.ActionWorkspaceStop,
|
||||
policy.ActionCreateAgent,
|
||||
policy.ActionDeleteAgent,
|
||||
policy.ActionUpdateAgent,
|
||||
},
|
||||
// Can read their own organization member record.
|
||||
ResourceOrganizationMember.Type: {
|
||||
policy.ActionRead,
|
||||
},
|
||||
})...,
|
||||
)
|
||||
|
||||
return OrgRolePermissions{Org: orgPerms, Member: memberPerms}
|
||||
}
|
||||
|
||||
+53
-39
@@ -51,54 +51,68 @@ func TestBuiltInRoles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemRolesAreReservedRoleNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, rbac.ReservedRoleName(rbac.RoleOrgMember()))
|
||||
// permissionGranted checks whether a permission list contains a
|
||||
// matching entry for the target, accounting for wildcard actions.
|
||||
// It does not evaluate negations that may override a positive grant.
|
||||
func permissionGranted(perms []rbac.Permission, target rbac.Permission) bool {
|
||||
return slices.ContainsFunc(perms, func(p rbac.Permission) bool {
|
||||
return p.Negate == target.Negate &&
|
||||
p.ResourceType == target.ResourceType &&
|
||||
(p.Action == target.Action || p.Action == policy.WildcardSymbol)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrgMemberPermissions(t *testing.T) {
|
||||
func TestOrgSharingPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("WorkspaceSharingEnabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
permsFunc func(rbac.OrgSettings) rbac.OrgRolePermissions
|
||||
mode rbac.ShareableWorkspaceOwners
|
||||
orgReadMembers bool
|
||||
orgReadGroups bool
|
||||
orgNegateShare bool
|
||||
memberNegateShare bool
|
||||
}{
|
||||
{"Member/Everyone", rbac.OrgMemberPermissions, rbac.ShareableWorkspaceOwnersEveryone, true, true, false, false},
|
||||
{"Member/None", rbac.OrgMemberPermissions, rbac.ShareableWorkspaceOwnersNone, false, false, true, true},
|
||||
{"Member/ServiceAccounts", rbac.OrgMemberPermissions, rbac.ShareableWorkspaceOwnersServiceAccounts, true, false, false, true},
|
||||
{"ServiceAccount/Everyone", rbac.OrgServiceAccountPermissions, rbac.ShareableWorkspaceOwnersEveryone, true, true, false, false},
|
||||
{"ServiceAccount/None", rbac.OrgServiceAccountPermissions, rbac.ShareableWorkspaceOwnersNone, false, false, true, false},
|
||||
{"ServiceAccount/ServiceAccounts", rbac.OrgServiceAccountPermissions, rbac.ShareableWorkspaceOwnersServiceAccounts, true, true, false, false},
|
||||
}
|
||||
|
||||
orgPerms, _ := rbac.OrgMemberPermissions(false)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, slices.Contains(orgPerms, rbac.Permission{
|
||||
ResourceType: rbac.ResourceOrganizationMember.Type,
|
||||
Action: policy.ActionRead,
|
||||
}))
|
||||
require.True(t, slices.Contains(orgPerms, rbac.Permission{
|
||||
ResourceType: rbac.ResourceGroup.Type,
|
||||
Action: policy.ActionRead,
|
||||
}))
|
||||
require.False(t, slices.Contains(orgPerms, rbac.Permission{
|
||||
Negate: true,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.ActionShare,
|
||||
}))
|
||||
})
|
||||
perms := tt.permsFunc(rbac.OrgSettings{
|
||||
ShareableWorkspaceOwners: tt.mode,
|
||||
})
|
||||
|
||||
t.Run("WorkspaceSharingDisabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tt.orgReadMembers, permissionGranted(perms.Org, rbac.Permission{
|
||||
ResourceType: rbac.ResourceOrganizationMember.Type,
|
||||
Action: policy.ActionRead,
|
||||
}), "org read members")
|
||||
|
||||
orgPerms, _ := rbac.OrgMemberPermissions(true)
|
||||
assert.Equal(t, tt.orgReadGroups, permissionGranted(perms.Org, rbac.Permission{
|
||||
ResourceType: rbac.ResourceGroup.Type,
|
||||
Action: policy.ActionRead,
|
||||
}), "org read groups")
|
||||
|
||||
require.False(t, slices.Contains(orgPerms, rbac.Permission{
|
||||
ResourceType: rbac.ResourceOrganizationMember.Type,
|
||||
Action: policy.ActionRead,
|
||||
}))
|
||||
require.False(t, slices.Contains(orgPerms, rbac.Permission{
|
||||
ResourceType: rbac.ResourceGroup.Type,
|
||||
Action: policy.ActionRead,
|
||||
}))
|
||||
require.True(t, slices.Contains(orgPerms, rbac.Permission{
|
||||
Negate: true,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.ActionShare,
|
||||
}))
|
||||
})
|
||||
assert.Equal(t, tt.orgNegateShare, permissionGranted(perms.Org, rbac.Permission{
|
||||
Negate: true,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.ActionShare,
|
||||
}), "org negate share")
|
||||
|
||||
assert.Equal(t, tt.memberNegateShare, permissionGranted(perms.Member, rbac.Permission{
|
||||
Negate: true,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.ActionShare,
|
||||
}), "member negate share")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:tparallel,paralleltest
|
||||
|
||||
@@ -2,6 +2,7 @@ package rolestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -161,13 +162,28 @@ func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) {
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// ReconcileSystemRoles ensures that every organization's org-member
|
||||
// system role in the DB is up-to-date with permissions reflecting
|
||||
// current RBAC resources and the organization's
|
||||
// workspace_sharing_disabled setting. Uses PostgreSQL advisory lock
|
||||
// (LockIDReconcileSystemRoles) to safely handle multi-instance
|
||||
// deployments. Uses set-based comparison to avoid unnecessary
|
||||
// database writes when permissions haven't changed.
|
||||
// System roles are defined in code but stored in the database,
|
||||
// allowing their permissions to be adjusted per-organization at
|
||||
// runtime based on org settings (e.g. workspace sharing).
|
||||
var systemRoles = map[string]permissionsFunc{
|
||||
rbac.RoleOrgMember(): rbac.OrgMemberPermissions,
|
||||
rbac.RoleOrgServiceAccount(): rbac.OrgServiceAccountPermissions,
|
||||
}
|
||||
|
||||
// permissionsFunc produces the desired permissions for a system role
|
||||
// given organization settings.
|
||||
type permissionsFunc func(rbac.OrgSettings) rbac.OrgRolePermissions
|
||||
|
||||
func IsSystemRoleName(name string) bool {
|
||||
_, ok := systemRoles[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
var SystemRoleNames = maps.Keys(systemRoles)
|
||||
|
||||
// ReconcileSystemRoles ensures that every organization's system roles
|
||||
// in the DB are up-to-date with the current RBAC definitions and
|
||||
// organization settings.
|
||||
func ReconcileSystemRoles(ctx context.Context, log slog.Logger, db database.Store) error {
|
||||
return db.InTx(func(tx database.Store) error {
|
||||
// Acquire advisory lock to prevent concurrent updates from
|
||||
@@ -193,36 +209,45 @@ func ReconcileSystemRoles(ctx context.Context, log slog.Logger, db database.Stor
|
||||
return xerrors.Errorf("fetch custom roles: %w", err)
|
||||
}
|
||||
|
||||
// Find org-member roles and index by organization ID for quick lookup.
|
||||
rolesByOrg := make(map[uuid.UUID]database.CustomRole)
|
||||
// Index system roles by (org ID, role name) for quick lookup.
|
||||
type orgRoleKey struct {
|
||||
OrgID uuid.UUID
|
||||
RoleName string
|
||||
}
|
||||
roleIndex := make(map[orgRoleKey]database.CustomRole)
|
||||
for _, role := range customRoles {
|
||||
if role.IsSystem && role.Name == rbac.RoleOrgMember() && role.OrganizationID.Valid {
|
||||
rolesByOrg[role.OrganizationID.UUID] = role
|
||||
if role.IsSystem && IsSystemRoleName(role.Name) && role.OrganizationID.Valid {
|
||||
roleIndex[orgRoleKey{role.OrganizationID.UUID, role.Name}] = role
|
||||
}
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
role, exists := rolesByOrg[org.ID]
|
||||
if !exists {
|
||||
// Something is very wrong: the role should have been created by the
|
||||
// database trigger or migration. Log loudly and try creating it as
|
||||
// a last-ditch effort before giving up.
|
||||
log.Critical(ctx, "missing organization-member system role; trying to re-create",
|
||||
slog.F("organization_id", org.ID))
|
||||
for roleName := range systemRoles {
|
||||
role, exists := roleIndex[orgRoleKey{org.ID, roleName}]
|
||||
if !exists {
|
||||
// Something is very wrong: the role should have been
|
||||
// created by the db trigger or migration. Log loudly and
|
||||
// try creating it as a last-ditch effort before giving up.
|
||||
log.Critical(ctx, "missing system role; trying to re-create",
|
||||
slog.F("organization_id", org.ID),
|
||||
slog.F("role_name", roleName))
|
||||
|
||||
if err := CreateOrgMemberRole(ctx, tx, org); err != nil {
|
||||
return xerrors.Errorf("create missing organization-member role for organization %s: %w",
|
||||
org.ID, err)
|
||||
err := CreateSystemRole(ctx, tx, org, roleName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create missing %s system role for organization %s: %w",
|
||||
roleName, org.ID, err)
|
||||
}
|
||||
|
||||
// Nothing more to do; the new role's permissions are
|
||||
// up-to-date.
|
||||
continue
|
||||
}
|
||||
|
||||
// Nothing more to do; the new role's permissions are up-to-date.
|
||||
continue
|
||||
}
|
||||
|
||||
_, _, err := ReconcileOrgMemberRole(ctx, tx, role, org.WorkspaceSharingDisabled)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reconcile organization-member role for organization %s: %w",
|
||||
org.ID, err)
|
||||
_, _, err := ReconcileSystemRole(ctx, tx, role, org)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reconcile %s system role for organization %s: %w",
|
||||
roleName, org.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,28 +255,30 @@ func ReconcileSystemRoles(ctx context.Context, log slog.Logger, db database.Stor
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// ReconcileOrgMemberRole ensures passed-in org-member role's perms
|
||||
// are correct (current) and stored in the DB. Uses set-based
|
||||
// comparison to avoid unnecessary database writes when permissions
|
||||
// haven't changed. Returns the correct role and a boolean indicating
|
||||
// whether the reconciliation was necessary.
|
||||
// NOTE: Callers must acquire `database.LockIDReconcileSystemRoles` at
|
||||
// the start of the transaction and hold it for the transaction’s
|
||||
// duration. This prevents concurrent org-member reconciliation from
|
||||
// racing and producing inconsistent writes.
|
||||
func ReconcileOrgMemberRole(
|
||||
// ReconcileSystemRole compares the given role's permissions against
|
||||
// the desired permissions produced by the permissions function based
|
||||
// on the organization's settings. If they differ, the DB row is
|
||||
// updated. Uses set-based comparison so permission ordering doesn't
|
||||
// matter. Returns the correct role and a boolean indicating whether
|
||||
// the reconciliation was necessary.
|
||||
//
|
||||
// IMPORTANT: Callers must hold database.LockIDReconcileSystemRoles
|
||||
// for the duration of the enclosing transaction.
|
||||
func ReconcileSystemRole(
|
||||
ctx context.Context,
|
||||
tx database.Store,
|
||||
in database.CustomRole,
|
||||
workspaceSharingDisabled bool,
|
||||
) (
|
||||
database.CustomRole, bool, error,
|
||||
) {
|
||||
org database.Organization,
|
||||
) (database.CustomRole, bool, error) {
|
||||
permsFunc, ok := systemRoles[in.Name]
|
||||
if !ok {
|
||||
panic("dev error: no permissions function exists for role " + in.Name)
|
||||
}
|
||||
|
||||
// All fields except OrgPermissions and MemberPermissions will be the same.
|
||||
out := in
|
||||
|
||||
// Paranoia check: we don't use these in custom roles yet.
|
||||
// TODO(geokat): Have these as check constraints in DB for now?
|
||||
out.SitePermissions = database.CustomRolePermissions{}
|
||||
out.UserPermissions = database.CustomRolePermissions{}
|
||||
out.DisplayName = ""
|
||||
@@ -259,15 +286,14 @@ func ReconcileOrgMemberRole(
|
||||
inOrgPerms := ConvertDBPermissions(in.OrgPermissions)
|
||||
inMemberPerms := ConvertDBPermissions(in.MemberPermissions)
|
||||
|
||||
outOrgPerms, outMemberPerms := rbac.OrgMemberPermissions(workspaceSharingDisabled)
|
||||
outPerms := permsFunc(orgSettings(org))
|
||||
|
||||
// Compare using set-based comparison (order doesn't matter).
|
||||
match := rbac.PermissionsEqual(inOrgPerms, outOrgPerms) &&
|
||||
rbac.PermissionsEqual(inMemberPerms, outMemberPerms)
|
||||
match := rbac.PermissionsEqual(inOrgPerms, outPerms.Org) &&
|
||||
rbac.PermissionsEqual(inMemberPerms, outPerms.Member)
|
||||
|
||||
if !match {
|
||||
out.OrgPermissions = ConvertPermissionsToDB(outOrgPerms)
|
||||
out.MemberPermissions = ConvertPermissionsToDB(outMemberPerms)
|
||||
out.OrgPermissions = ConvertPermissionsToDB(outPerms.Org)
|
||||
out.MemberPermissions = ConvertPermissionsToDB(outPerms.Member)
|
||||
|
||||
_, err := tx.UpdateCustomRole(ctx, database.UpdateCustomRoleParams{
|
||||
Name: out.Name,
|
||||
@@ -279,30 +305,50 @@ func ReconcileOrgMemberRole(
|
||||
MemberPermissions: out.MemberPermissions,
|
||||
})
|
||||
if err != nil {
|
||||
return out, !match, xerrors.Errorf("update organization-member custom role for organization %s: %w",
|
||||
in.OrganizationID.UUID, err)
|
||||
return out, !match, xerrors.Errorf("update %s system role for organization %s: %w",
|
||||
in.Name, in.OrganizationID.UUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return out, !match, nil
|
||||
}
|
||||
|
||||
// CreateOrgMemberRole creates an org-member system role for an organization.
|
||||
func CreateOrgMemberRole(ctx context.Context, tx database.Store, org database.Organization) error {
|
||||
orgPerms, memberPerms := rbac.OrgMemberPermissions(org.WorkspaceSharingDisabled)
|
||||
// orgSettings maps database.Organization fields to the
|
||||
// rbac.OrgSettings struct, bridging the database and rbac packages
|
||||
// without introducing a circular dependency.
|
||||
func orgSettings(org database.Organization) rbac.OrgSettings {
|
||||
return rbac.OrgSettings{
|
||||
ShareableWorkspaceOwners: rbac.ShareableWorkspaceOwners(org.ShareableWorkspaceOwners),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSystemRole inserts a new system role into the database with
|
||||
// permissions produced by permsFunc based on the organization's current
|
||||
// settings.
|
||||
func CreateSystemRole(
|
||||
ctx context.Context,
|
||||
tx database.Store,
|
||||
org database.Organization,
|
||||
roleName string,
|
||||
) error {
|
||||
permsFunc, ok := systemRoles[roleName]
|
||||
if !ok {
|
||||
panic("dev error: no permissions function exists for role " + roleName)
|
||||
}
|
||||
perms := permsFunc(orgSettings(org))
|
||||
|
||||
_, err := tx.InsertCustomRole(ctx, database.InsertCustomRoleParams{
|
||||
Name: rbac.RoleOrgMember(),
|
||||
Name: roleName,
|
||||
DisplayName: "",
|
||||
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
||||
SitePermissions: database.CustomRolePermissions{},
|
||||
OrgPermissions: ConvertPermissionsToDB(orgPerms),
|
||||
OrgPermissions: ConvertPermissionsToDB(perms.Org),
|
||||
UserPermissions: database.CustomRolePermissions{},
|
||||
MemberPermissions: ConvertPermissionsToDB(memberPerms),
|
||||
MemberPermissions: ConvertPermissionsToDB(perms.Member),
|
||||
IsSystem: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert org-member role: %w", err)
|
||||
return xerrors.Errorf("insert %s role: %w", roleName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -42,68 +42,84 @@ func TestExpandCustomRoleRoles(t *testing.T) {
|
||||
require.Len(t, roles, 1, "role found")
|
||||
}
|
||||
|
||||
func TestReconcileOrgMemberRole(t *testing.T) {
|
||||
func TestReconcileSystemRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
roleName string
|
||||
permsFunc func(rbac.OrgSettings) rbac.OrgRolePermissions
|
||||
}{
|
||||
{"OrgMember", rbac.RoleOrgMember(), rbac.OrgMemberPermissions},
|
||||
{"ServiceAccount", rbac.RoleOrgServiceAccount(), rbac.OrgServiceAccountPermissions},
|
||||
}
|
||||
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
existing, err := database.ExpectOne(db.CustomRoles(ctx, database.CustomRolesParams{
|
||||
LookupRoles: []database.NameOrganizationPair{
|
||||
{
|
||||
Name: rbac.RoleOrgMember(),
|
||||
OrganizationID: org.ID,
|
||||
},
|
||||
},
|
||||
IncludeSystemRoles: true,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
existing, err := database.ExpectOne(db.CustomRoles(ctx, database.CustomRolesParams{
|
||||
LookupRoles: []database.NameOrganizationPair{
|
||||
{
|
||||
Name: tt.roleName,
|
||||
OrganizationID: org.ID,
|
||||
},
|
||||
},
|
||||
IncludeSystemRoles: true,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.UpdateCustomRole(ctx, database.UpdateCustomRoleParams{
|
||||
Name: existing.Name,
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: org.ID,
|
||||
Valid: true,
|
||||
},
|
||||
DisplayName: "",
|
||||
SitePermissions: database.CustomRolePermissions{},
|
||||
UserPermissions: database.CustomRolePermissions{},
|
||||
OrgPermissions: database.CustomRolePermissions{},
|
||||
MemberPermissions: database.CustomRolePermissions{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Zero out permissions to simulate stale state.
|
||||
_, err = db.UpdateCustomRole(ctx, database.UpdateCustomRoleParams{
|
||||
Name: existing.Name,
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: org.ID,
|
||||
Valid: true,
|
||||
},
|
||||
DisplayName: "",
|
||||
SitePermissions: database.CustomRolePermissions{},
|
||||
UserPermissions: database.CustomRolePermissions{},
|
||||
OrgPermissions: database.CustomRolePermissions{},
|
||||
MemberPermissions: database.CustomRolePermissions{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
stale := existing
|
||||
stale.OrgPermissions = database.CustomRolePermissions{}
|
||||
stale.MemberPermissions = database.CustomRolePermissions{}
|
||||
stale := existing
|
||||
stale.OrgPermissions = database.CustomRolePermissions{}
|
||||
stale.MemberPermissions = database.CustomRolePermissions{}
|
||||
|
||||
reconciled, didUpdate, err := rolestore.ReconcileOrgMemberRole(ctx, db, stale, org.WorkspaceSharingDisabled)
|
||||
require.NoError(t, err)
|
||||
require.True(t, didUpdate, "expected reconciliation to update stale permissions")
|
||||
reconciled, didUpdate, err := rolestore.ReconcileSystemRole(ctx, db, stale, org)
|
||||
require.NoError(t, err)
|
||||
require.True(t, didUpdate, "expected reconciliation to update stale permissions")
|
||||
|
||||
got, err := database.ExpectOne(db.CustomRoles(ctx, database.CustomRolesParams{
|
||||
LookupRoles: []database.NameOrganizationPair{
|
||||
{
|
||||
Name: rbac.RoleOrgMember(),
|
||||
OrganizationID: org.ID,
|
||||
},
|
||||
},
|
||||
IncludeSystemRoles: true,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
dbstored, err := database.ExpectOne(db.CustomRoles(ctx, database.CustomRolesParams{
|
||||
LookupRoles: []database.NameOrganizationPair{
|
||||
{
|
||||
Name: tt.roleName,
|
||||
OrganizationID: org.ID,
|
||||
},
|
||||
},
|
||||
IncludeSystemRoles: true,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
|
||||
wantOrg, wantMember := rbac.OrgMemberPermissions(org.WorkspaceSharingDisabled)
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(got.OrgPermissions), wantOrg))
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(got.MemberPermissions), wantMember))
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(reconciled.OrgPermissions), wantOrg))
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(reconciled.MemberPermissions), wantMember))
|
||||
want := tt.permsFunc(rbac.OrgSettings{
|
||||
ShareableWorkspaceOwners: rbac.ShareableWorkspaceOwners(org.ShareableWorkspaceOwners),
|
||||
})
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(dbstored.OrgPermissions), want.Org))
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(dbstored.MemberPermissions), want.Member))
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(reconciled.OrgPermissions), want.Org))
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(reconciled.MemberPermissions), want.Member))
|
||||
|
||||
_, didUpdate, err = rolestore.ReconcileOrgMemberRole(ctx, db, reconciled, org.WorkspaceSharingDisabled)
|
||||
require.NoError(t, err)
|
||||
require.False(t, didUpdate, "expected no-op reconciliation when permissions are already current")
|
||||
_, didUpdate, err = rolestore.ReconcileSystemRole(ctx, db, reconciled, org)
|
||||
require.NoError(t, err)
|
||||
require.False(t, didUpdate, "expected no-op reconciliation when permissions are already current")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileSystemRoles(t *testing.T) {
|
||||
@@ -118,7 +134,7 @@ func TestReconcileSystemRoles(t *testing.T) {
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
_, err := sqlDB.ExecContext(ctx, "UPDATE organizations SET workspace_sharing_disabled = true WHERE id = $1", org2.ID)
|
||||
_, err := sqlDB.ExecContext(ctx, "UPDATE organizations SET shareable_workspace_owners = 'none' WHERE id = $1", org2.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate a missing system role by bypassing the application's
|
||||
@@ -163,9 +179,9 @@ func TestReconcileSystemRoles(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.IsSystem)
|
||||
|
||||
wantOrg, wantMember := rbac.OrgMemberPermissions(org.WorkspaceSharingDisabled)
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(got.OrgPermissions), wantOrg))
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(got.MemberPermissions), wantMember))
|
||||
want := rbac.OrgMemberPermissions(rbac.OrgSettings{ShareableWorkspaceOwners: rbac.ShareableWorkspaceOwners(org.ShareableWorkspaceOwners)})
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(got.OrgPermissions), want.Org))
|
||||
require.True(t, rbac.PermissionsEqual(rolestore.ConvertDBPermissions(got.MemberPermissions), want.Member))
|
||||
}
|
||||
|
||||
assertOrgMemberRole(t, org1.ID)
|
||||
|
||||
@@ -2487,7 +2487,11 @@ func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
httpapi.Forbidden(rw)
|
||||
} else {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2566,7 +2570,7 @@ func (api *API) allowWorkspaceSharing(ctx context.Context, rw http.ResponseWrite
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return false
|
||||
}
|
||||
if org.WorkspaceSharingDisabled {
|
||||
if org.ShareableWorkspaceOwners == database.ShareableWorkspaceOwnersNone {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Workspace sharing is disabled for this organization.",
|
||||
})
|
||||
|
||||
@@ -7,19 +7,41 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ShareableWorkspaceOwners controls whose workspaces can be shared
|
||||
// within an organization.
|
||||
type ShareableWorkspaceOwners string
|
||||
|
||||
const (
|
||||
ShareableWorkspaceOwnersNone ShareableWorkspaceOwners = "none"
|
||||
ShareableWorkspaceOwnersEveryone ShareableWorkspaceOwners = "everyone"
|
||||
ShareableWorkspaceOwnersServiceAccounts ShareableWorkspaceOwners = "service_accounts"
|
||||
)
|
||||
|
||||
// WorkspaceSharingSettings represents workspace sharing settings affecting an
|
||||
// organization.
|
||||
type WorkspaceSharingSettings struct {
|
||||
// SharingGloballyDisabled is true if sharing has been disabled for this
|
||||
// organization because of a deployment-wide setting.
|
||||
SharingGloballyDisabled bool `json:"sharing_globally_disabled"`
|
||||
SharingDisabled bool `json:"sharing_disabled"`
|
||||
// SharingDisabled is deprecated and left for backward compatibility
|
||||
// purposes.
|
||||
// Deprecated: use `ShareableWorkspaceOwners` instead
|
||||
SharingDisabled bool `json:"sharing_disabled"`
|
||||
// ShareableWorkspaceOwners controls whose workspaces can be shared
|
||||
// within the organization.
|
||||
ShareableWorkspaceOwners ShareableWorkspaceOwners `json:"shareable_workspace_owners" enums:"none,everyone,service_accounts"`
|
||||
}
|
||||
|
||||
// UpdateWorkspaceSharingSettingsRequest represents workspace sharing settings
|
||||
// that can be updated for an organization.
|
||||
type UpdateWorkspaceSharingSettingsRequest struct {
|
||||
// SharingDisabled is deprecated and left for backward compatibility
|
||||
// purposes.
|
||||
// Deprecated: use `ShareableWorkspaceOwners` instead
|
||||
SharingDisabled bool `json:"sharing_disabled"`
|
||||
// ShareableWorkspaceOwners controls whose workspaces can be shared
|
||||
// within the organization.
|
||||
ShareableWorkspaceOwners ShareableWorkspaceOwners `json:"shareable_workspace_owners,omitempty" enums:"none,everyone,service_accounts"`
|
||||
}
|
||||
|
||||
// WorkspaceSharingSettings retrieves the workspace sharing settings for an organization.
|
||||
|
||||
@@ -29,7 +29,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>updated_at</td><td>true</td></tr><tr><td>workspace_sharing_disabled</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>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
+13
-10
@@ -2851,6 +2851,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/setting
|
||||
|
||||
```json
|
||||
{
|
||||
"shareable_workspace_owners": "none",
|
||||
"sharing_disabled": true,
|
||||
"sharing_globally_disabled": true
|
||||
}
|
||||
@@ -2882,17 +2883,17 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization}/setti
|
||||
|
||||
```json
|
||||
{
|
||||
"sharing_disabled": true,
|
||||
"sharing_globally_disabled": true
|
||||
"shareable_workspace_owners": "none",
|
||||
"sharing_disabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|----------------|------|----------------------------------------------------------------------------------|----------|----------------------------|
|
||||
| `organization` | path | string(uuid) | true | Organization ID |
|
||||
| `body` | body | [codersdk.WorkspaceSharingSettings](schemas.md#codersdkworkspacesharingsettings) | true | Workspace sharing settings |
|
||||
| Name | In | Type | Required | Description |
|
||||
|----------------|------|------------------------------------------------------------------------------------------------------------|----------|----------------------------|
|
||||
| `organization` | path | string(uuid) | true | Organization ID |
|
||||
| `body` | body | [codersdk.UpdateWorkspaceSharingSettingsRequest](schemas.md#codersdkupdateworkspacesharingsettingsrequest) | true | Workspace sharing settings |
|
||||
|
||||
### Example responses
|
||||
|
||||
@@ -2900,15 +2901,17 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization}/setti
|
||||
|
||||
```json
|
||||
{
|
||||
"sharing_disabled": true
|
||||
"shareable_workspace_owners": "none",
|
||||
"sharing_disabled": true,
|
||||
"sharing_globally_disabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UpdateWorkspaceSharingSettingsRequest](schemas.md#codersdkupdateworkspacesharingsettingsrequest) |
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceSharingSettings](schemas.md#codersdkworkspacesharingsettings) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
|
||||
Generated
+37
-7
@@ -8011,6 +8011,20 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|
||||
| `max_token_lifetime` | integer | false | | |
|
||||
| `refresh_default_duration` | integer | false | | Refresh default duration is the default lifetime for OAuth2 refresh tokens. This should generally be longer than access token lifetimes to allow refreshing after access token expiry. |
|
||||
|
||||
## codersdk.ShareableWorkspaceOwners
|
||||
|
||||
```json
|
||||
"none"
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value(s) |
|
||||
|----------------------------------------|
|
||||
| `everyone`, `none`, `service_accounts` |
|
||||
|
||||
## codersdk.SharedWorkspaceActor
|
||||
|
||||
```json
|
||||
@@ -9829,15 +9843,23 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
|
||||
```json
|
||||
{
|
||||
"shareable_workspace_owners": "none",
|
||||
"sharing_disabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|--------------------|---------|----------|--------------|-------------|
|
||||
| `sharing_disabled` | boolean | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|------------------------------|------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `shareable_workspace_owners` | [codersdk.ShareableWorkspaceOwners](#codersdkshareableworkspaceowners) | false | | Shareable workspace owners controls whose workspaces can be shared within the organization. |
|
||||
| `sharing_disabled` | boolean | false | | Sharing disabled is deprecated and left for backward compatibility purposes. Deprecated: use `ShareableWorkspaceOwners` instead |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|------------------------------|----------------------------------------|
|
||||
| `shareable_workspace_owners` | `everyone`, `none`, `service_accounts` |
|
||||
|
||||
## codersdk.UpdateWorkspaceTTLRequest
|
||||
|
||||
@@ -12308,6 +12330,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
|
||||
```json
|
||||
{
|
||||
"shareable_workspace_owners": "none",
|
||||
"sharing_disabled": true,
|
||||
"sharing_globally_disabled": true
|
||||
}
|
||||
@@ -12315,10 +12338,17 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-----------------------------|---------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------|
|
||||
| `sharing_disabled` | boolean | false | | |
|
||||
| `sharing_globally_disabled` | boolean | false | | Sharing globally disabled is true if sharing has been disabled for this organization because of a deployment-wide setting. |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|------------------------------|------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `shareable_workspace_owners` | [codersdk.ShareableWorkspaceOwners](#codersdkshareableworkspaceowners) | false | | Shareable workspace owners controls whose workspaces can be shared within the organization. |
|
||||
| `sharing_disabled` | boolean | false | | Sharing disabled is deprecated and left for backward compatibility purposes. Deprecated: use `ShareableWorkspaceOwners` instead |
|
||||
| `sharing_globally_disabled` | boolean | false | | Sharing globally disabled is true if sharing has been disabled for this organization because of a deployment-wide setting. |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|------------------------------|----------------------------------------|
|
||||
| `shareable_workspace_owners` | `everyone`, `none`, `service_accounts` |
|
||||
|
||||
## codersdk.WorkspaceStatus
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
||||
"is_default": ActionTrack,
|
||||
"display_name": ActionTrack,
|
||||
"icon": ActionTrack,
|
||||
"workspace_sharing_disabled": ActionTrack,
|
||||
"shareable_workspace_owners": ActionTrack,
|
||||
},
|
||||
&database.NotificationTemplate{}: {
|
||||
"id": ActionIgnore,
|
||||
|
||||
@@ -737,19 +737,19 @@ func TestGroup(t *testing.T) {
|
||||
},
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
_, err := sqlDB.ExecContext(ctx, "UPDATE organizations SET workspace_sharing_disabled = true WHERE id = $1", user.OrganizationID)
|
||||
_, err := sqlDB.ExecContext(ctx, "UPDATE organizations SET shareable_workspace_owners = 'none' WHERE id = $1", user.OrganizationID)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gocritic // ReconcileOrgMemberRole needs the system:update
|
||||
// permission that the test context doesn't have.
|
||||
sysCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
_, _, err = rolestore.ReconcileOrgMemberRole(sysCtx, api.Database, database.CustomRole{
|
||||
_, _, err = rolestore.ReconcileSystemRole(sysCtx, api.Database, database.CustomRole{
|
||||
Name: rbac.RoleOrgMember(),
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: user.OrganizationID,
|
||||
Valid: true,
|
||||
},
|
||||
}, true)
|
||||
}, database.Organization{ShareableWorkspaceOwners: database.ShareableWorkspaceOwnersNone})
|
||||
require.NoError(t, err)
|
||||
|
||||
client1, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
)
|
||||
@@ -298,16 +297,15 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) {
|
||||
//nolint:gocritic // ReconcileOrgMemberRole needs the system:update
|
||||
// permission that user doesn't have.
|
||||
sysCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
_, _, err = rolestore.ReconcileOrgMemberRole(sysCtx, tx, database.CustomRole{
|
||||
Name: rbac.RoleOrgMember(),
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: organizationID,
|
||||
Valid: true,
|
||||
},
|
||||
}, organization.WorkspaceSharingDisabled)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reconcile organization-member role for organization %s: %w",
|
||||
organizationID, err)
|
||||
for roleName := range rolestore.SystemRoleNames {
|
||||
_, _, err = rolestore.ReconcileSystemRole(sysCtx, tx, database.CustomRole{
|
||||
Name: roleName,
|
||||
OrganizationID: uuid.NullUUID{UUID: organizationID, Valid: true},
|
||||
}, organization)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reconcile %s role for organization %s: %w",
|
||||
roleName, organizationID, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{
|
||||
|
||||
@@ -288,7 +288,8 @@ func TestCustomOrganizationRole(t *testing.T) {
|
||||
require.ErrorContains(t, err, "not allowed to assign organization member permissions for an organization role")
|
||||
})
|
||||
|
||||
// Attempt to delete a system role, which is not allowed.
|
||||
// System roles are stored in the DB but excluded from the custom
|
||||
// roles API, so attempting to delete one returns 404.
|
||||
t.Run("DeleteSystemRole", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -306,8 +307,7 @@ func TestCustomOrganizationRole(t *testing.T) {
|
||||
err := owner.DeleteOrganizationRole(ctx, first.OrganizationID, rbac.RoleOrgMember())
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
require.ErrorContains(t, err, "Reserved role name")
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/rbac/rolestore"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -34,10 +37,16 @@ func (api *API) workspaceSharingSettings(rw http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
disabled := org.ShareableWorkspaceOwners == database.ShareableWorkspaceOwnersNone
|
||||
globallyDisabled := bool(api.DeploymentValues.DisableWorkspaceSharing)
|
||||
owners := codersdk.ShareableWorkspaceOwners(org.ShareableWorkspaceOwners)
|
||||
if globallyDisabled {
|
||||
owners = codersdk.ShareableWorkspaceOwnersNone
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceSharingSettings{
|
||||
SharingGloballyDisabled: globallyDisabled,
|
||||
SharingDisabled: org.WorkspaceSharingDisabled || globallyDisabled,
|
||||
SharingGloballyDisabled: globallyDisabled,
|
||||
SharingDisabled: disabled || globallyDisabled,
|
||||
ShareableWorkspaceOwners: owners,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,8 +57,8 @@ func (api *API) workspaceSharingSettings(rw http.ResponseWriter, r *http.Request
|
||||
// @Accept json
|
||||
// @Tags Enterprise
|
||||
// @Param organization path string true "Organization ID" format(uuid)
|
||||
// @Param request body codersdk.WorkspaceSharingSettings true "Workspace sharing settings"
|
||||
// @Success 200 {object} codersdk.UpdateWorkspaceSharingSettingsRequest
|
||||
// @Param request body codersdk.UpdateWorkspaceSharingSettingsRequest true "Workspace sharing settings"
|
||||
// @Success 200 {object} codersdk.WorkspaceSharingSettings
|
||||
// @Router /organizations/{organization}/settings/workspace-sharing [patch]
|
||||
func (api *API) patchWorkspaceSharingSettings(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
@@ -70,19 +79,44 @@ func (api *API) patchWorkspaceSharingSettings(rw http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.WorkspaceSharingSettings
|
||||
var req codersdk.UpdateWorkspaceSharingSettingsRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the effective enum value. Prefer the new field; fall
|
||||
// back to the deprecated boolean for older clients (e.g
|
||||
// tf-provider-coderd v0.0.16)
|
||||
allowedOwners := req.ShareableWorkspaceOwners
|
||||
if allowedOwners == "" {
|
||||
if req.SharingDisabled {
|
||||
allowedOwners = codersdk.ShareableWorkspaceOwnersNone
|
||||
} else {
|
||||
allowedOwners = codersdk.ShareableWorkspaceOwnersEveryone
|
||||
}
|
||||
}
|
||||
|
||||
if !database.ShareableWorkspaceOwners(allowedOwners).Valid() {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid shareable workspace owners value.",
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "shareable_workspace_owners",
|
||||
Detail: fmt.Sprintf("invalid value %q, must be one of [%s]",
|
||||
allowedOwners,
|
||||
strings.Join(slice.ToStrings(database.AllShareableWorkspaceOwnersValues()), ", ")),
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := api.Database.InTx(func(tx database.Store) error {
|
||||
//nolint:gocritic // System context required to look up and reconcile the
|
||||
// organization-member system role; callers only need `organization:update`
|
||||
// system roles; callers only need `organization:update`
|
||||
sysCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
// Serialize organization workspace-sharing updates with system role
|
||||
// reconciliation across coderd instances (e.g. during rolling restarts).
|
||||
// This prevents conflicting writes to the organization-member system role.
|
||||
// This prevents conflicting writes to the system roles.
|
||||
// TODO(geokat): Consider finer-grained locks as we add more system roles.
|
||||
err := tx.AcquireLock(ctx, database.LockIDReconcileSystemRoles)
|
||||
if err != nil {
|
||||
@@ -91,38 +125,54 @@ func (api *API) patchWorkspaceSharingSettings(rw http.ResponseWriter, r *http.Re
|
||||
|
||||
org, err = tx.UpdateOrganizationWorkspaceSharingSettings(ctx, database.UpdateOrganizationWorkspaceSharingSettingsParams{
|
||||
ID: org.ID,
|
||||
WorkspaceSharingDisabled: req.SharingDisabled,
|
||||
ShareableWorkspaceOwners: database.ShareableWorkspaceOwners(allowedOwners),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update organization workspace sharing settings: %w", err)
|
||||
return xerrors.Errorf("update workspace sharing settings for organization %s: %w",
|
||||
org.ID, err)
|
||||
}
|
||||
|
||||
role, err := database.ExpectOne(tx.CustomRoles(sysCtx, database.CustomRolesParams{
|
||||
roles, err := tx.CustomRoles(sysCtx, database.CustomRolesParams{
|
||||
LookupRoles: []database.NameOrganizationPair{
|
||||
{
|
||||
Name: rbac.RoleOrgMember(),
|
||||
OrganizationID: org.ID,
|
||||
},
|
||||
{
|
||||
Name: rbac.RoleOrgServiceAccount(),
|
||||
OrganizationID: org.ID,
|
||||
},
|
||||
},
|
||||
// Satisfy linter that requires all fields to be set.
|
||||
OrganizationID: org.ID,
|
||||
ExcludeOrgRoles: false,
|
||||
IncludeSystemRoles: true,
|
||||
}))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get organization-member role: %w", err)
|
||||
})
|
||||
if err != nil || len(roles) != 2 {
|
||||
return xerrors.Errorf("get member and service-account roles for organization %s: %w",
|
||||
org.ID, err)
|
||||
}
|
||||
|
||||
_, _, err = rolestore.ReconcileOrgMemberRole(sysCtx, tx, role, req.SharingDisabled)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reconcile organization-member role: %w", err)
|
||||
}
|
||||
|
||||
if req.SharingDisabled {
|
||||
err = tx.DeleteWorkspaceACLsByOrganization(sysCtx, org.ID)
|
||||
for _, role := range roles {
|
||||
_, _, err = rolestore.ReconcileSystemRole(sysCtx, tx, role, org)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete workspace ACLs by organization: %w", err)
|
||||
return xerrors.Errorf("reconcile %s role for organization %s: %w",
|
||||
role.Name, org.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// If sharing is not enabled, delete workspace ACLs to prevent
|
||||
// ongoing shared use. In "service_accounts" mode, preserve
|
||||
// ACLs on SA workspaces.
|
||||
if org.ShareableWorkspaceOwners != database.ShareableWorkspaceOwnersEveryone {
|
||||
err = tx.DeleteWorkspaceACLsByOrganization(sysCtx, database.DeleteWorkspaceACLsByOrganizationParams{
|
||||
OrganizationID: org.ID,
|
||||
ExcludeServiceAccounts: org.ShareableWorkspaceOwners == database.ShareableWorkspaceOwnersServiceAccounts,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete workspace ACLs for organization %s: %w",
|
||||
org.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +188,7 @@ func (api *API) patchWorkspaceSharingSettings(rw http.ResponseWriter, r *http.Re
|
||||
|
||||
aReq.New = org
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceSharingSettings{
|
||||
SharingDisabled: org.WorkspaceSharingDisabled,
|
||||
SharingDisabled: org.ShareableWorkspaceOwners == database.ShareableWorkspaceOwnersNone,
|
||||
ShareableWorkspaceOwners: codersdk.ShareableWorkspaceOwners(org.ShareableWorkspaceOwners),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
@@ -34,10 +36,13 @@ func TestWorkspaceSharingSettings(t *testing.T) {
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
// Use a regular user to make sure the setting is exposed to them.
|
||||
memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
|
||||
settings, err := memberClient.WorkspaceSharingSettings(ctx, first.OrganizationID.String())
|
||||
require.NoError(t, err)
|
||||
// Check the deprecated boolean field.
|
||||
require.False(t, settings.SharingDisabled)
|
||||
require.Equal(t, codersdk.ShareableWorkspaceOwnersEveryone, settings.ShareableWorkspaceOwners)
|
||||
})
|
||||
|
||||
t.Run("DisabledTogglePersists", func(t *testing.T) {
|
||||
@@ -54,21 +59,59 @@ func TestWorkspaceSharingSettings(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID))
|
||||
|
||||
// Disable sharing via the deprecated boolean field.
|
||||
settings, err := orgAdminClient.PatchWorkspaceSharingSettings(ctx, first.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
SharingDisabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, settings.SharingDisabled)
|
||||
require.Equal(t, codersdk.ShareableWorkspaceOwnersNone, settings.ShareableWorkspaceOwners)
|
||||
|
||||
settings, err = orgAdminClient.WorkspaceSharingSettings(ctx, first.OrganizationID.String())
|
||||
require.NoError(t, err)
|
||||
require.True(t, settings.SharingDisabled)
|
||||
require.Equal(t, codersdk.ShareableWorkspaceOwnersNone, settings.ShareableWorkspaceOwners)
|
||||
|
||||
// Switch to service_accounts mode via the new field.
|
||||
settings, err = orgAdminClient.PatchWorkspaceSharingSettings(ctx, first.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
SharingDisabled: false,
|
||||
ShareableWorkspaceOwners: codersdk.ShareableWorkspaceOwnersServiceAccounts,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, settings.SharingDisabled)
|
||||
require.Equal(t, codersdk.ShareableWorkspaceOwnersServiceAccounts, settings.ShareableWorkspaceOwners)
|
||||
|
||||
settings, err = orgAdminClient.WorkspaceSharingSettings(ctx, first.OrganizationID.String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.ShareableWorkspaceOwnersServiceAccounts, settings.ShareableWorkspaceOwners)
|
||||
|
||||
// Re-enable full sharing.
|
||||
settings, err = orgAdminClient.PatchWorkspaceSharingSettings(ctx, first.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
ShareableWorkspaceOwners: codersdk.ShareableWorkspaceOwnersEveryone,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, settings.SharingDisabled)
|
||||
require.Equal(t, codersdk.ShareableWorkspaceOwnersEveryone, settings.ShareableWorkspaceOwners)
|
||||
|
||||
settings, err = orgAdminClient.WorkspaceSharingSettings(ctx, first.OrganizationID.String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.ShareableWorkspaceOwnersEveryone, settings.ShareableWorkspaceOwners)
|
||||
})
|
||||
|
||||
t.Run("InvalidValueRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, first := coderdenttest.New(t, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID))
|
||||
_, err := orgAdminClient.PatchWorkspaceSharingSettings(ctx, first.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
ShareableWorkspaceOwners: "invalid",
|
||||
})
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("UpdateAuthz", func(t *testing.T) {
|
||||
@@ -153,7 +196,7 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
|
||||
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
|
||||
_, err := orgAdminClient.PatchWorkspaceSharingSettings(ctx, owner.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
SharingDisabled: true,
|
||||
ShareableWorkspaceOwners: codersdk.ShareableWorkspaceOwnersNone,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -185,6 +228,132 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
|
||||
assertSharingDisabled(t, err)
|
||||
})
|
||||
|
||||
t.Run("ACLEndpointsForbiddenServiceAccountsMode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db, owner := coderdenttest.NewWithDatabase(t, nil)
|
||||
|
||||
regularClient, regularUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
regularWS := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: regularUser.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
}).Do().Workspace
|
||||
|
||||
// Create an SA with a workspace.
|
||||
saClient, saUser := coderdtest.CreateAnotherUserMutators(t, client, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||
r.ServiceAccount = true
|
||||
})
|
||||
saWS := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: saUser.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
}).Do().Workspace
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
orgAdminClient, orgAdmin := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
|
||||
_, err := orgAdminClient.PatchWorkspaceSharingSettings(ctx, owner.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
ShareableWorkspaceOwners: codersdk.ShareableWorkspaceOwnersServiceAccounts,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Regular member cannot share their own workspace.
|
||||
err = regularClient.UpdateWorkspaceACL(ctx, regularWS.ID, codersdk.UpdateWorkspaceACL{
|
||||
UserRoles: map[string]codersdk.WorkspaceRole{
|
||||
orgAdmin.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
},
|
||||
})
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
|
||||
// SA can share their own workspace.
|
||||
err = saClient.UpdateWorkspaceACL(ctx, saWS.ID, codersdk.UpdateWorkspaceACL{
|
||||
UserRoles: map[string]codersdk.WorkspaceRole{
|
||||
regularUser.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
// Future-proofing: if custom roles with member-scoped
|
||||
// workspace:share are ever allowed, the member-level negation
|
||||
// from the organization-member system role must block sharing in
|
||||
// service_accounts mode even with such custom role.
|
||||
t.Run("MemberCannotBypassWithCustomRole", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawDB, pubsub, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||
client, _, _, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
Database: rawDB,
|
||||
Pubsub: pubsub,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureCustomRoles: 1,
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
// Create an empty custom role via the API, then add
|
||||
// member-scoped workspace:share via raw SQL (the API and
|
||||
// dbauthz both reject member permissions on custom roles).
|
||||
//nolint:gocritic // owner context required for role creation
|
||||
customRole, err := client.CreateOrganizationRole(ctx, codersdk.Role{
|
||||
Name: "workspace-share-granter",
|
||||
OrganizationID: owner.OrganizationID.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = sqlDB.ExecContext(ctx,
|
||||
`UPDATE custom_roles SET member_permissions = $1 WHERE name = $2 AND organization_id = $3`,
|
||||
database.CustomRolePermissions{{
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.ActionShare,
|
||||
}},
|
||||
customRole.Name,
|
||||
owner.OrganizationID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a member and assign the custom role.
|
||||
memberClient, memberUser := coderdtest.CreateAnotherUserMutators(
|
||||
t, client, owner.OrganizationID,
|
||||
[]rbac.RoleIdentifier{{
|
||||
Name: customRole.Name,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
}},
|
||||
)
|
||||
memberWS := dbfake.WorkspaceBuild(t, rawDB, database.WorkspaceTable{
|
||||
OwnerID: memberUser.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
}).Do().Workspace
|
||||
|
||||
_, sharedUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
// Switch to service_accounts mode.
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
|
||||
_, err = orgAdminClient.PatchWorkspaceSharingSettings(ctx, owner.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
ShareableWorkspaceOwners: codersdk.ShareableWorkspaceOwnersServiceAccounts,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Despite the custom role granting workspace:share at the
|
||||
// member level, the negation from organization-member should
|
||||
// block it.
|
||||
err = memberClient.UpdateWorkspaceACL(ctx, memberWS.ID, codersdk.UpdateWorkspaceACL{
|
||||
UserRoles: map[string]codersdk.WorkspaceRole{
|
||||
sharedUser.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
},
|
||||
})
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("ACLsPurged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -236,12 +405,12 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
|
||||
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
|
||||
_, err = orgAdminClient.PatchWorkspaceSharingSettings(ctx, owner.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
SharingDisabled: true,
|
||||
ShareableWorkspaceOwners: codersdk.ShareableWorkspaceOwnersNone,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = orgAdminClient.PatchWorkspaceSharingSettings(ctx, owner.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
SharingDisabled: false,
|
||||
ShareableWorkspaceOwners: codersdk.ShareableWorkspaceOwnersEveryone,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -263,4 +432,76 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
|
||||
require.Len(t, acl.Users, 1)
|
||||
require.Equal(t, sharedUser.ID, acl.Users[0].ID)
|
||||
})
|
||||
|
||||
t.Run("ACLsPurgedExceptServiceAccounts", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
|
||||
client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Regular user with a workspace.
|
||||
workspaceOwnerClient, workspaceOwner := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
_, sharedUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
regularWS := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: workspaceOwner.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
}).Do().Workspace
|
||||
|
||||
// Service account with a workspace.
|
||||
_, saUser := coderdtest.CreateAnotherUserMutators(t, client, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||
r.ServiceAccount = true
|
||||
})
|
||||
saWS := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: saUser.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
}).Do().Workspace
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
// Share regular user's workspace with sharedUser.
|
||||
err := workspaceOwnerClient.UpdateWorkspaceACL(ctx, regularWS.ID, codersdk.UpdateWorkspaceACL{
|
||||
UserRoles: map[string]codersdk.WorkspaceRole{
|
||||
sharedUser.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use the owner client (site admin) to share the SA workspace,
|
||||
// since the SA can't authenticate via the API.
|
||||
err = client.UpdateWorkspaceACL(ctx, saWS.ID, codersdk.UpdateWorkspaceACL{
|
||||
UserRoles: map[string]codersdk.WorkspaceRole{
|
||||
sharedUser.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Switch to service_accounts mode.
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
|
||||
_, err = orgAdminClient.PatchWorkspaceSharingSettings(ctx, owner.OrganizationID.String(), codersdk.UpdateWorkspaceSharingSettingsRequest{
|
||||
ShareableWorkspaceOwners: codersdk.ShareableWorkspaceOwnersServiceAccounts,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Regular user workspace ACLs should be purged.
|
||||
acl, err := workspaceOwnerClient.WorkspaceACL(ctx, regularWS.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, acl.Users)
|
||||
|
||||
// Service account workspace ACLs should be preserved.
|
||||
acl, err = client.WorkspaceACL(ctx, saWS.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, acl.Users, 1)
|
||||
require.Equal(t, sharedUser.ID, acl.Users[0].ID)
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+29
@@ -5707,6 +5707,15 @@ export interface SessionLifetime {
|
||||
*/
|
||||
export const SessionTokenHeader = "Coder-Session-Token";
|
||||
|
||||
// From codersdk/workspacesharing.go
|
||||
export type ShareableWorkspaceOwners = "everyone" | "none" | "service_accounts";
|
||||
|
||||
export const ShareableWorkspaceOwnerses: ShareableWorkspaceOwners[] = [
|
||||
"everyone",
|
||||
"none",
|
||||
"service_accounts",
|
||||
];
|
||||
|
||||
// From codersdk/workspaces.go
|
||||
export interface SharedWorkspaceActor {
|
||||
readonly id: string;
|
||||
@@ -6891,7 +6900,17 @@ export interface UpdateWorkspaceRequest {
|
||||
* that can be updated for an organization.
|
||||
*/
|
||||
export interface UpdateWorkspaceSharingSettingsRequest {
|
||||
/**
|
||||
* SharingDisabled is deprecated and left for backward compatibility
|
||||
* purposes.
|
||||
* Deprecated: use `ShareableWorkspaceOwners` instead
|
||||
*/
|
||||
readonly sharing_disabled: boolean;
|
||||
/**
|
||||
* ShareableWorkspaceOwners controls whose workspaces can be shared
|
||||
* within the organization.
|
||||
*/
|
||||
readonly shareable_workspace_owners?: ShareableWorkspaceOwners;
|
||||
}
|
||||
|
||||
// From codersdk/workspaces.go
|
||||
@@ -8043,7 +8062,17 @@ export interface WorkspaceSharingSettings {
|
||||
* organization because of a deployment-wide setting.
|
||||
*/
|
||||
readonly sharing_globally_disabled: boolean;
|
||||
/**
|
||||
* SharingDisabled is deprecated and left for backward compatibility
|
||||
* purposes.
|
||||
* Deprecated: use `ShareableWorkspaceOwners` instead
|
||||
*/
|
||||
readonly sharing_disabled: boolean;
|
||||
/**
|
||||
* ShareableWorkspaceOwners controls whose workspaces can be shared
|
||||
* within the organization.
|
||||
*/
|
||||
readonly shareable_workspace_owners: ShareableWorkspaceOwners;
|
||||
}
|
||||
|
||||
// From codersdk/workspacebuilds.go
|
||||
|
||||
Reference in New Issue
Block a user