mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
cc2efe9e1f
Migrated the built-in organization-member role to DB storage so it can be customized per org. Closes https://github.com/coder/internal/issues/1073 (part 1)
310 lines
10 KiB
Go
310 lines
10 KiB
Go
package rolestore
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
|
||
"github.com/google/uuid"
|
||
"golang.org/x/xerrors"
|
||
|
||
"cdr.dev/slog/v3"
|
||
"github.com/coder/coder/v2/coderd/database"
|
||
"github.com/coder/coder/v2/coderd/rbac"
|
||
"github.com/coder/coder/v2/coderd/util/syncmap"
|
||
)
|
||
|
||
type customRoleCtxKey struct{}
|
||
|
||
// CustomRoleMW adds a custom role cache on the ctx to prevent duplicate
|
||
// db fetches.
|
||
func CustomRoleMW(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
r = r.WithContext(CustomRoleCacheContext(r.Context()))
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// CustomRoleCacheContext prevents needing to lookup custom roles within the
|
||
// same request lifecycle. Optimizing this to span requests should be done
|
||
// in the future.
|
||
func CustomRoleCacheContext(ctx context.Context) context.Context {
|
||
return context.WithValue(ctx, customRoleCtxKey{}, syncmap.New[string, rbac.Role]())
|
||
}
|
||
|
||
func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] {
|
||
c, ok := ctx.Value(customRoleCtxKey{}).(*syncmap.Map[string, rbac.Role])
|
||
if !ok {
|
||
return syncmap.New[string, rbac.Role]()
|
||
}
|
||
return c
|
||
}
|
||
|
||
// Expand will expand built in roles, and fetch custom roles from the database.
|
||
// If a custom role is defined, but does not exist, the role will be omitted on
|
||
// the response. This means deleted roles are silently dropped.
|
||
func Expand(ctx context.Context, db database.Store, names []rbac.RoleIdentifier) (rbac.Roles, error) {
|
||
if len(names) == 0 {
|
||
// That was easy
|
||
return []rbac.Role{}, nil
|
||
}
|
||
|
||
cache := roleCache(ctx)
|
||
lookup := make([]rbac.RoleIdentifier, 0)
|
||
roles := make([]rbac.Role, 0, len(names))
|
||
|
||
for _, name := range names {
|
||
// Remove any built in roles
|
||
expanded, err := rbac.RoleByName(name)
|
||
if err == nil {
|
||
roles = append(roles, expanded)
|
||
continue
|
||
}
|
||
|
||
// Check custom role cache
|
||
customRole, ok := cache.Load(name.String())
|
||
if ok {
|
||
roles = append(roles, customRole)
|
||
continue
|
||
}
|
||
|
||
// Defer custom role lookup
|
||
lookup = append(lookup, name)
|
||
}
|
||
|
||
if len(lookup) > 0 {
|
||
lookupArgs := make([]database.NameOrganizationPair, 0, len(lookup))
|
||
for _, name := range lookup {
|
||
lookupArgs = append(lookupArgs, database.NameOrganizationPair{
|
||
Name: name.Name,
|
||
OrganizationID: name.OrganizationID,
|
||
})
|
||
}
|
||
|
||
// If some roles are missing from the database, they are omitted from
|
||
// the expansion. These roles are no-ops. Should we raise some kind of
|
||
// warning when this happens?
|
||
dbroles, err := db.CustomRoles(ctx, database.CustomRolesParams{
|
||
LookupRoles: lookupArgs,
|
||
ExcludeOrgRoles: false,
|
||
OrganizationID: uuid.Nil,
|
||
IncludeSystemRoles: true,
|
||
})
|
||
if err != nil {
|
||
return nil, xerrors.Errorf("fetch custom roles: %w", err)
|
||
}
|
||
|
||
// convert dbroles -> roles
|
||
for _, dbrole := range dbroles {
|
||
converted, err := ConvertDBRole(dbrole)
|
||
if err != nil {
|
||
return nil, xerrors.Errorf("convert db role %q: %w", dbrole.Name, err)
|
||
}
|
||
roles = append(roles, converted)
|
||
cache.Store(dbrole.RoleIdentifier().String(), converted)
|
||
}
|
||
}
|
||
|
||
return roles, nil
|
||
}
|
||
|
||
// ConvertDBPermissions converts database permissions to RBAC permissions.
|
||
func ConvertDBPermissions(dbPerms []database.CustomRolePermission) []rbac.Permission {
|
||
n := make([]rbac.Permission, 0, len(dbPerms))
|
||
for _, dbPerm := range dbPerms {
|
||
n = append(n, rbac.Permission{
|
||
Negate: dbPerm.Negate,
|
||
ResourceType: dbPerm.ResourceType,
|
||
Action: dbPerm.Action,
|
||
})
|
||
}
|
||
return n
|
||
}
|
||
|
||
// ConvertPermissionsToDB converts RBAC permissions to the database
|
||
// format.
|
||
func ConvertPermissionsToDB(perms []rbac.Permission) []database.CustomRolePermission {
|
||
dbPerms := make([]database.CustomRolePermission, 0, len(perms))
|
||
for _, perm := range perms {
|
||
dbPerms = append(dbPerms, database.CustomRolePermission{
|
||
Negate: perm.Negate,
|
||
ResourceType: perm.ResourceType,
|
||
Action: perm.Action,
|
||
})
|
||
}
|
||
return dbPerms
|
||
}
|
||
|
||
// ConvertDBRole should not be used by any human facing apis. It is used
|
||
// for authz purposes.
|
||
func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) {
|
||
role := rbac.Role{
|
||
Identifier: dbRole.RoleIdentifier(),
|
||
DisplayName: dbRole.DisplayName,
|
||
Site: ConvertDBPermissions(dbRole.SitePermissions),
|
||
User: ConvertDBPermissions(dbRole.UserPermissions),
|
||
}
|
||
|
||
// Org permissions only make sense if an org id is specified.
|
||
if len(dbRole.OrgPermissions) > 0 && dbRole.OrganizationID.UUID == uuid.Nil {
|
||
return rbac.Role{}, xerrors.Errorf("role has organization perms without an org id specified")
|
||
}
|
||
|
||
if dbRole.OrganizationID.UUID != uuid.Nil {
|
||
role.ByOrgID = map[string]rbac.OrgPermissions{
|
||
dbRole.OrganizationID.UUID.String(): {
|
||
Org: ConvertDBPermissions(dbRole.OrgPermissions),
|
||
Member: ConvertDBPermissions(dbRole.MemberPermissions),
|
||
},
|
||
}
|
||
}
|
||
|
||
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.
|
||
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
|
||
// multiple coderd instances. Other instances will block here
|
||
// until we release the lock (when this transaction commits).
|
||
err := tx.AcquireLock(ctx, database.LockIDReconcileSystemRoles)
|
||
if err != nil {
|
||
return xerrors.Errorf("acquire system roles reconciliation lock: %w", err)
|
||
}
|
||
|
||
orgs, err := tx.GetOrganizations(ctx, database.GetOrganizationsParams{})
|
||
if err != nil {
|
||
return xerrors.Errorf("fetch organizations: %w", err)
|
||
}
|
||
|
||
customRoles, err := tx.CustomRoles(ctx, database.CustomRolesParams{
|
||
LookupRoles: nil,
|
||
ExcludeOrgRoles: false,
|
||
OrganizationID: uuid.Nil,
|
||
IncludeSystemRoles: true,
|
||
})
|
||
if err != nil {
|
||
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)
|
||
for _, role := range customRoles {
|
||
if role.IsSystem && role.Name == rbac.RoleOrgMember() && role.OrganizationID.Valid {
|
||
rolesByOrg[role.OrganizationID.UUID] = 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))
|
||
|
||
if err := CreateOrgMemberRole(ctx, tx, org); err != nil {
|
||
return xerrors.Errorf("create missing organization-member role for organization %s: %w",
|
||
org.ID, err)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}, 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(
|
||
ctx context.Context,
|
||
tx database.Store,
|
||
in database.CustomRole,
|
||
workspaceSharingDisabled bool,
|
||
) (
|
||
database.CustomRole, bool, error,
|
||
) {
|
||
// 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 = ""
|
||
|
||
inOrgPerms := ConvertDBPermissions(in.OrgPermissions)
|
||
inMemberPerms := ConvertDBPermissions(in.MemberPermissions)
|
||
|
||
outOrgPerms, outMemberPerms := rbac.OrgMemberPermissions(workspaceSharingDisabled)
|
||
|
||
// Compare using set-based comparison (order doesn't matter).
|
||
match := rbac.PermissionsEqual(inOrgPerms, outOrgPerms) &&
|
||
rbac.PermissionsEqual(inMemberPerms, outMemberPerms)
|
||
|
||
if !match {
|
||
out.OrgPermissions = ConvertPermissionsToDB(outOrgPerms)
|
||
out.MemberPermissions = ConvertPermissionsToDB(outMemberPerms)
|
||
|
||
_, err := tx.UpdateCustomRole(ctx, database.UpdateCustomRoleParams{
|
||
Name: out.Name,
|
||
OrganizationID: out.OrganizationID,
|
||
DisplayName: out.DisplayName,
|
||
SitePermissions: out.SitePermissions,
|
||
UserPermissions: out.UserPermissions,
|
||
OrgPermissions: out.OrgPermissions,
|
||
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, 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)
|
||
|
||
_, err := tx.InsertCustomRole(ctx, database.InsertCustomRoleParams{
|
||
Name: rbac.RoleOrgMember(),
|
||
DisplayName: "",
|
||
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
||
SitePermissions: database.CustomRolePermissions{},
|
||
OrgPermissions: ConvertPermissionsToDB(orgPerms),
|
||
UserPermissions: database.CustomRolePermissions{},
|
||
MemberPermissions: ConvertPermissionsToDB(memberPerms),
|
||
IsSystem: true,
|
||
})
|
||
if err != nil {
|
||
return xerrors.Errorf("insert org-member role: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|