mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00: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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user