8000 feat: implement organization role sync by Emyrk · Pull Request #14649 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

feat: implement organization role sync #14649

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Sep 17, 2024
Prev Previous commit
Next Next commit
chore: implement organization and site wide role sync in idpsync
  • Loading branch information
Emyrk committed Sep 16, 2024
commit c6080b5a64164c24e9b30f02b101c54aac6c3784
4 changes: 2 additions & 2 deletions coderd/idpsync/idpsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/site"
"github.com/coder/serpent"
)

// IDPSync is an interface, so we can implement this as AGPL and as enterprise,
Expand Down Expand Up @@ -80,7 +79,7 @@ type DeploymentSyncSettings struct {

// SiteRoleField syncs a user's site wide roles from an IDP.
SiteRoleField string
SiteRoleMapping serpent.Struct[map[string][]string]
SiteRoleMapping map[string][]string
SiteDefaultRoles []string
}

Expand Down Expand Up @@ -128,6 +127,7 @@ func NewAGPLSync(logger slog.Logger, manager *runtimeconfig.Manager, settings De
SyncSettings: SyncSettings{
DeploymentSyncSettings: settings,
Group: runtimeconfig.MustNew[*GroupSyncSettings]("group-sync-settings"),
Role: runtimeconfig.MustNew[*RoleSyncSettings]("role-sync-settings"),
},
}
}
Expand Down
210 changes: 208 additions & 2 deletions coderd/idpsync/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,27 @@ package idpsync

import (
"context"
"encoding/json"

"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"

"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/coderd/runtimeconfig"
)

type RoleParams struct {
// SyncEnabled if false will skip syncing the user's roles
SyncEnabled bool
SyncEnabled bool
SyncSiteWide bool
SiteWideRoles []string
// MergedClaims are passed to the organization level for syncing
MergedClaims jwt.MapClaims
}

Expand All @@ -26,7 +36,8 @@ func (s AGPLIDPSync) RoleSyncSettings() runtimeconfig.RuntimeEntry[*RoleSyncSett

func (s AGPLIDPSync) ParseRoleClaims(_ context.Context, _ jwt.MapClaims) (RoleParams, *HTTPError) {
return RoleParams{
SyncEnabled: s.RoleSyncEnabled(),
SyncEnabled: s.RoleSyncEnabled(),
SyncSiteWide: false,
}, nil
}

Expand All @@ -39,9 +50,196 @@ func (s AGPLIDPSync) SyncRoles(ctx context.Context, db database.Store, user data
// nolint:gocritic // all syncing is done as a system user
ctx = dbauthz.AsSystemRestricted(ctx)

err := db.InTx(func(tx database.Store) error {
if params.SyncSiteWide {
if err := s.syncSiteWideRoles(ctx, tx, user, params); err != nil {
return err
}
}

// sync roles per organization
orgMemberships, err := tx.OrganizationMembers(ctx, database.OrganizationMembersParams{
UserID: user.ID,
})
if err != nil {
return xerrors.Errorf("get organizations by user id: %w", err)
}

// Sync for each organization
// If a key for a given org exists in the map, the user's roles will be
// updated to the value of that key.
expectedRoles := make(map[uuid.UUID][]rbac.RoleIdentifier)
existingRoles := make(map[uuid.UUID][]string)
allExpected := make([]rbac.RoleIdentifier, 0)
for _, member := range orgMemberships {
orgID := member.OrganizationMember.OrganizationID
orgResolver := s.Manager.OrganizationResolver(tx, orgID)
settings, err := s.RoleSyncSettings().Resolve(ctx, orgResolver)
if err != nil {
if !xerrors.Is(err, runtimeconfig.ErrEntryNotFound) {
return xerrors.Errorf("resolve group sync settings: %w", err)
}
// No entry means no role syncing for this organization
continue
}
if settings.Field == "" {
// Explicitly disabled role sync for this organization
continue
}

existingRoles[orgID] = member.OrganizationMember.Roles
orgRoleClaims, err := s.RolesFromClaim(settings.Field, params.MergedClaims)
if err != nil {
s.Logger.Error(ctx, "failed to parse roles from claim",
slog.F("field", settings.Field),
slog.F("organization_id", orgID),
slog.F("user_id", user.ID),
slog.F("username", user.Username),
slog.Error(err),
)

// Failing role sync should reset a user's roles.
expectedRoles[orgID] = []rbac.RoleIdentifier{}

// Do not return an error, because that would prevent a user
// from logging in. A misconfigured organization should not
// stop a user from logging into the site.
continue
}

expected := make([]rbac.RoleIdentifier, 0, len(orgRoleClaims))
for _, role := range orgRoleClaims {
if mappedRoles, ok := settings.Mapping[role]; ok {
for _, mappedRole := range mappedRoles {
expected = append(expected, rbac.RoleIdentifier{OrganizationID: orgID, Name: mappedRole})
}
continue
}
expected = append(expected, rbac.RoleIdentifier{OrganizationID: orgID, Name: role})
}

expectedRoles[orgID] = expected
allExpected = append(allExpected, expected...)
}

// Now mass sync the user's org membership roles.
validRoles, err := rolestore.Expand(ctx, tx, allExpected)
if err != nil {
return xerrors.Errorf("expand roles: %w", err)
}
validMap := make(map[string]struct{}, len(validRoles))
for _, validRole := range validRoles {
validMap[validRole.Identifier.UniqueName()] = struct{}{}
}

// For each org, do the SQL query to update the user's roles.
// TODO: Would be better to batch all these into a single SQL query.
for orgID, roles := range expectedRoles {
validExpected := make([]string, 0, len(roles))
for _, role := range roles {
if _, ok := validMap[role.UniqueName()]; ok {
validExpected = append(validExpected, role.Name)
}
}
// Always add the member role to the user.
validExpected = append(validExpected, rbac.RoleOrgMember())

// Is there a difference between the expected roles and the existing roles?
if !slices.Equal(existingRoles[orgID], validExpected) {
_, err = tx.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{
GrantedRoles: validExpected,
UserID: user.ID,
OrgID: orgID,
})
if err != nil {
return xerrors.Errorf("update member roles(%s): %w", user.ID.String(), err)
}
}
}
return nil
}, nil)
if err != nil {
return xerrors.Errorf("sync user roles(%s): %w", user.ID.String(), err)
}

return nil
}

// resetUserOrgRoles will reset the user's roles for a specific organization.
// It does not remove them as a member from the organization.
func (s AGPLIDPSync) resetUserOrgRoles(ctx context.Context, tx database.Store, member database.OrganizationMembersRow, orgID uuid.UUID) error {
withoutMember := slices.DeleteFunc(member.OrganizationMember.Roles, func(s string) bool {
return s == rbac.RoleOrgMember()
})
// If the user has no roles, then skip doing any database request.
if len(withoutMember) == 0 {
return nil
}

_, err := tx.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{
GrantedRoles: []string{},
UserID: member.OrganizationMember.UserID,
OrgID: orgID,
})
if err != nil {
return xerrors.Errorf("zero out member roles(%s): %w", member.OrganizationMember.UserID.String(), err)
}
return nil
}

func (s AGPLIDPSync) syncSiteWideRoles(ctx context.Context, tx database.Store, user database.User, params RoleParams) error {
// Apply site wide roles to a user.
// ignored is the list of roles that are not valid Coder roles and will
// be skipped.
ignored := make([]string, 0)
filtered := make([]string, 0, len(params.SiteWideRoles))
for _, role := range params.SiteWideRoles {
// Because we are only syncing site wide roles, we intentionally will always
// omit 'OrganizationID' from the RoleIdentifier.
if _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: role}); err == nil {
filtered = append(filtered, role)
} else {
ignored = append(ignored, role)
}
}
if len(ignored) > 0 {
s.Logger.Debug(ctx, "OIDC roles ignored in assignment",
slog.F("ignored", ignored),
slog.F("assigned", filtered),
slog.F("user_id", user.ID),
slog.F("username", user.Username),
)
Comment on lines +220 to +225
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

_, err := tx.UpdateUserRoles(ctx, database.UpdateUserRolesParams{
GrantedRoles: filtered,
ID: user.ID,
})
if err != nil {
return xerrors.Errorf("set site wide roles: %w", err)
}
return nil
}

func (s AGPLIDPSync) RolesFromClaim(field string, claims jwt.MapClaims) ([]string, error) {
rolesRow, ok := claims[field]
if !ok {
// If no claim is provided than we can assume the user is just
// a member. This is because there is no way to tell the difference
// between []string{} and nil for OIDC claims. IDPs omit claims
// if they are empty ([]string{}).
// Use []interface{}{} so the next typecast works.
rolesRow = []interface{}{}
}

parsedRoles, err := ParseStringSliceClaim(rolesRow)
if err != nil {
return nil, xerrors.Errorf("failed to parse roles from claim: %w", err)
}

return parsedRoles, nil
}

type RoleSyncSettings struct {
// Field selects the claim field to be used as the created user's
// groups. If the group field is the empty string, then no group updates
Expand All @@ -50,3 +248,11 @@ type RoleSyncSettings struct {
// Mapping maps from an OIDC group --> Coder organization role
Mapping map[string][]string `json:"mapping"`
}

func (s *RoleSyncSettings) Set(v string) error {
return json.Unmarshal([]byte(v), s)
}

func (s *RoleSyncSettings) String() string {
return runtimeconfig.JSONString(s)
}
8 changes: 4 additions & 4 deletions coderd/runtimeconfig/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func MustNew[T EntryValue](name string) RuntimeEntry[T] {
}

// SetRuntimeValue attempts to update the runtime value of this field in the store via the given Mutator.
func (e *RuntimeEntry[T]) SetRuntimeValue(ctx context.Context, m Resolver, val T) error {
func (e RuntimeEntry[T]) SetRuntimeValue(ctx context.Context, m Resolver, val T) error {
name, err := e.name()
if err != nil {
return xerrors.Errorf("set runtime: %w", err)
Expand All @@ -56,7 +56,7 @@ func (e *RuntimeEntry[T]) SetRuntimeValue(ctx context.Context, m Resolver, val T
}

// UnsetRuntimeValue removes the runtime value from the store.
func (e *RuntimeEntry[T]) UnsetRuntimeValue(ctx context.Context, m Resolver) error {
func (e RuntimeEntry[T]) UnsetRuntimeValue(ctx context.Context, m Resolver) error {
name, err := e.name()
if err != nil {
return xerrors.Errorf("unset runtime: %w", err)
Expand All @@ -66,7 +66,7 @@ func (e *RuntimeEntry[T]) UnsetRuntimeValue(ctx context.Context, m Resolver) err
}

// Resolve attempts to resolve the runtime value of this field from the store via the given Resolver.
func (e *RuntimeEntry[T]) Resolve(ctx context.Context, r Resolver) (T, error) {
func (e RuntimeEntry[T]) Resolve(ctx context.Context, r Resolver) (T, error) {
var zero T

name, err := e.name()
Expand All @@ -87,7 +87,7 @@ func (e *RuntimeEntry[T]) Resolve(ctx context.Context, r Resolver) (T, error) {
}

// name returns the configured name, or fails with ErrNameNotSet.
func (e *RuntimeEntry[T]) name() (string, error) {
func (e RuntimeEntry[T]) name() (string, error) {
if e.n == "" {
return "", ErrNameNotSet
}
Expand Down
67 changes: 67 additions & 0 deletions enterprise/coderd/enidpsync/role.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package enidpsync

import (
"context"
"fmt"
"net/http"

"github.com/golang-jwt/jwt/v4"

"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/codersdk"
)

func (e EnterpriseIDPSync) RoleSyncEnabled() bool {
return e.entitlements.Enabled(codersdk.FeatureUserRoleManagement)
}

func (e EnterpriseIDPSync) ParseRoleClaims(ctx context.Context, mergedClaims jwt.MapClaims) (idpsync.RoleParams, *idpsync.HTTPError) {
if !e.RoleSyncEnabled() {
return e.AGPLIDPSync.ParseRoleClaims(ctx, mergedClaims)
}

var claimRoles []string
if e.AGPLIDPSync.SiteRoleField != "" {
var err error
// TODO: Smoke test this error for org and site
claimRoles, err = e.AGPLIDPSync.RolesFromClaim(e.AGPLIDPSync.SiteRoleField, mergedClaims)
if err != nil {
rawType := mergedClaims[e.AGPLIDPSync.SiteRoleField]
e.Logger.Error(ctx, "oidc claims user roles field was an unknown type",
slog.F("type", fmt.Sprintf("%T", rawType)),
slog.F("field", e.AGPLIDPSync.SiteRoleField),
slog.F("raw_value", rawType),
slog.Error(err),
)
// TODO: Deterine a static page or not
return idpsync.RoleParams{}, &idpsync.HTTPError{
Code: http.StatusInternalServerError,
Msg: "Login disabled until site wide OIDC config is fixed",
Detail: fmt.Sprintf("Roles claim must be an array of strings, type found: %T. Disabling role sync will allow login to proceed.", rawType),
RenderStaticPage: false,
}
}
}

siteRoles := append([]string{}, e.SiteDefaultRoles...)
for _, role := range claimRoles {
if mappedRoles, ok := e.SiteRoleMapping[role]; ok {
if len(mappedRoles) == 0 {
continue
}
// Mapped roles are added to the list of roles
siteRoles = append(siteRoles, mappedRoles...)
continue
}
// Append as is.
siteRoles = append(siteRoles, role)
}

return idpsync.RoleParams{
SyncEnabled: e.RoleSyncEnabled(),
SyncSiteWide: e.AGPLIDPSync.SiteRoleField != "",
SiteWideRoles: siteRoles,
MergedClaims: mergedClaims,
}, nil
}
0