Skip to content

Commit c6080b5

Browse files
committed
chore: implement organization and site wide role sync in idpsync
1 parent 9a73013 commit c6080b5

File tree

4 files changed

+281
-8
lines changed

4 files changed

+281
-8
lines changed

coderd/idpsync/idpsync.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import (
1616
"github.com/coder/coder/v2/coderd/runtimeconfig"
1717
"github.com/coder/coder/v2/codersdk"
1818
"github.com/coder/coder/v2/site"
19-
"github.com/coder/serpent"
2019
)
2120

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

8180
// SiteRoleField syncs a user's site wide roles from an IDP.
8281
SiteRoleField string
83-
SiteRoleMapping serpent.Struct[map[string][]string]
82+
SiteRoleMapping map[string][]string
8483
SiteDefaultRoles []string
8584
}
8685

@@ -128,6 +127,7 @@ func NewAGPLSync(logger slog.Logger, manager *runtimeconfig.Manager, settings De
128127
SyncSettings: SyncSettings{
129128
DeploymentSyncSettings: settings,
130129
Group: runtimeconfig.MustNew[*GroupSyncSettings]("group-sync-settings"),
130+
Role: runtimeconfig.MustNew[*RoleSyncSettings]("role-sync-settings"),
131131
},
132132
}
133133
}

coderd/idpsync/role.go

Lines changed: 208 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,27 @@ package idpsync
22

33
import (
44
"context"
5+
"encoding/json"
56

67
"github.com/golang-jwt/jwt/v4"
8+
"github.com/google/uuid"
9+
"golang.org/x/exp/slices"
10+
"golang.org/x/xerrors"
711

12+
"cdr.dev/slog"
813
"github.com/coder/coder/v2/coderd/database"
914
"github.com/coder/coder/v2/coderd/database/dbauthz"
15+
"github.com/coder/coder/v2/coderd/rbac"
16+
"github.com/coder/coder/v2/coderd/rbac/rolestore"
1017
"github.com/coder/coder/v2/coderd/runtimeconfig"
1118
)
1219

1320
type RoleParams struct {
1421
// SyncEnabled if false will skip syncing the user's roles
15-
SyncEnabled bool
22+
SyncEnabled bool
23+
SyncSiteWide bool
24+
SiteWideRoles []string
25+
// MergedClaims are passed to the organization level for syncing
1626
MergedClaims jwt.MapClaims
1727
}
1828

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

2737
func (s AGPLIDPSync) ParseRoleClaims(_ context.Context, _ jwt.MapClaims) (RoleParams, *HTTPError) {
2838
return RoleParams{
29-
SyncEnabled: s.RoleSyncEnabled(),
39+
SyncEnabled: s.RoleSyncEnabled(),
40+
SyncSiteWide: false,
3041
}, nil
3142
}
3243

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

53+
err := db.InTx(func(tx database.Store) error {
54+
if params.SyncSiteWide {
55+
if err := s.syncSiteWideRoles(ctx, tx, user, params); err != nil {
56+
return err
57+
}
58+
}
59+
60+
// sync roles per organization
61+
orgMemberships, err := tx.OrganizationMembers(ctx, database.OrganizationMembersParams{
62+
UserID: user.ID,
63+
})
64+
if err != nil {
65+
return xerrors.Errorf("get organizations by user id: %w", err)
66+
}
67+
68+
// Sync for each organization
69+
// If a key for a given org exists in the map, the user's roles will be
70+
// updated to the value of that key.
71+
expectedRoles := make(map[uuid.UUID][]rbac.RoleIdentifier)
72+
existingRoles := make(map[uuid.UUID][]string)
73+
allExpected := make([]rbac.RoleIdentifier, 0)
74+
for _, member := range orgMemberships {
75+
orgID := member.OrganizationMember.OrganizationID
76+
orgResolver := s.Manager.OrganizationResolver(tx, orgID)
77+
settings, err := s.RoleSyncSettings().Resolve(ctx, orgResolver)
78+
if err != nil {
79+
if !xerrors.Is(err, runtimeconfig.ErrEntryNotFound) {
80+
return xerrors.Errorf("resolve group sync settings: %w", err)
81+
}
82+
// No entry means no role syncing for this organization
83+
continue
84+
}
85+
if settings.Field == "" {
86+
// Explicitly disabled role sync for this organization
87+
continue
88+
}
89+
90+
existingRoles[orgID] = member.OrganizationMember.Roles
91+
orgRoleClaims, err := s.RolesFromClaim(settings.Field, params.MergedClaims)
92+
if err != nil {
93+
s.Logger.Error(ctx, "failed to parse roles from claim",
94+
slog.F("field", settings.Field),
95+
slog.F("organization_id", orgID),
96+
slog.F("user_id", user.ID),
97+
slog.F("username", user.Username),
98+
slog.Error(err),
99+
)
100+
101+
// Failing role sync should reset a user's roles.
102+
expectedRoles[orgID] = []rbac.RoleIdentifier{}
103+
104+
// Do not return an error, because that would prevent a user
105+
// from logging in. A misconfigured organization should not
106+
// stop a user from logging into the site.
107+
continue
108+
}
109+
110+
expected := make([]rbac.RoleIdentifier, 0, len(orgRoleClaims))
111+
for _, role := range orgRoleClaims {
112+
if mappedRoles, ok := settings.Mapping[role]; ok {
113+
for _, mappedRole := range mappedRoles {
114+
expected = append(expected, rbac.RoleIdentifier{OrganizationID: orgID, Name: mappedRole})
115+
}
116+
continue
117+
}
118+
expected = append(expected, rbac.RoleIdentifier{OrganizationID: orgID, Name: role})
119+
}
120+
121+
expectedRoles[orgID] = expected
122+
allExpected = append(allExpected, expected...)
123+
}
124+
125+
// Now mass sync the user's org membership roles.
126+
validRoles, err := rolestore.Expand(ctx, tx, allExpected)
127+
if err != nil {
128+
return xerrors.Errorf("expand roles: %w", err)
129+
}
130+
validMap := make(map[string]struct{}, len(validRoles))
131+
for _, validRole := range validRoles {
132+
validMap[validRole.Identifier.UniqueName()] = struct{}{}
133+
}
134+
135+
// For each org, do the SQL query to update the user's roles.
136+
// TODO: Would be better to batch all these into a single SQL query.
137+
for orgID, roles := range expectedRoles {
138+
validExpected := make([]string, 0, len(roles))
139+
for _, role := range roles {
140+
if _, ok := validMap[role.UniqueName()]; ok {
141+
validExpected = append(validExpected, role.Name)
142+
}
143+
}
144+
// Always add the member role to the user.
145+
validExpected = append(validExpected, rbac.RoleOrgMember())
146+
147+
// Is there a difference between the expected roles and the existing roles?
148+
if !slices.Equal(existingRoles[orgID], validExpected) {
149+
_, err = tx.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{
150+
GrantedRoles: validExpected,
151+
UserID: user.ID,
152+
OrgID: orgID,
153+
})
154+
if err != nil {
155+
return xerrors.Errorf("update member roles(%s): %w", user.ID.String(), err)
156+
}
157+
}
158+
}
159+
return nil
160+
}, nil)
161+
if err != nil {
162+
return xerrors.Errorf("sync user roles(%s): %w", user.ID.String(), err)
163+
}
164+
42165
return nil
43166
}
44167

168+
// resetUserOrgRoles will reset the user's roles for a specific organization.
169+
// It does not remove them as a member from the organization.
170+
func (s AGPLIDPSync) resetUserOrgRoles(ctx context.Context, tx database.Store, member database.OrganizationMembersRow, orgID uuid.UUID) error {
171+
withoutMember := slices.DeleteFunc(member.OrganizationMember.Roles, func(s string) bool {
172+
return s == rbac.RoleOrgMember()
173+
})
174+
// If the user has no roles, then skip doing any database request.
175+
if len(withoutMember) == 0 {
176+
return nil
177+
}
178+
179+
_, err := tx.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{
180+
GrantedRoles: []string{},
181+
UserID: member.OrganizationMember.UserID,
182+
OrgID: orgID,
183+
})
184+
if err != nil {
185+
return xerrors.Errorf("zero out member roles(%s): %w", member.OrganizationMember.UserID.String(), err)
186+
}
187+
return nil
188+
}
189+
190+
func (s AGPLIDPSync) syncSiteWideRoles(ctx context.Context, tx database.Store, user database.User, params RoleParams) error {
191+
// Apply site wide roles to a user.
192+
// ignored is the list of roles that are not valid Coder roles and will
193+
// be skipped.
194+
ignored := make([]string, 0)
195+
filtered := make([]string, 0, len(params.SiteWideRoles))
196+
for _, role := range params.SiteWideRoles {
197+
// Because we are only syncing site wide roles, we intentionally will always
198+
// omit 'OrganizationID' from the RoleIdentifier.
199+
if _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: role}); err == nil {
200+
filtered = append(filtered, role)
201+
} else {
202+
ignored = append(ignored, role)
203+
}
204+
}
205+
if len(ignored) > 0 {
206+
s.Logger.Debug(ctx, "OIDC roles ignored in assignment",
207+
slog.F("ignored", ignored),
208+
slog.F("assigned", filtered),
209+
slog.F("user_id", user.ID),
210+
slog.F("username", user.Username),
211+
)
212+
}
213+
214+
_, err := tx.UpdateUserRoles(ctx, database.UpdateUserRolesParams{
215+
GrantedRoles: filtered,
216+
ID: user.ID,
217+
})
218+
if err != nil {
219+
return xerrors.Errorf("set site wide roles: %w", err)
220+
}
221+
return nil
222+
}
223+
224+
func (s AGPLIDPSync) RolesFromClaim(field string, claims jwt.MapClaims) ([]string, error) {
225+
rolesRow, ok := claims[field]
226+
if !ok {
227+
// If no claim is provided than we can assume the user is just
228+
// a member. This is because there is no way to tell the difference
229+
// between []string{} and nil for OIDC claims. IDPs omit claims
230+
// if they are empty ([]string{}).
231+
// Use []interface{}{} so the next typecast works.
232+
rolesRow = []interface{}{}
233+
}
234+
235+
parsedRoles, err := ParseStringSliceClaim(rolesRow)
236+
if err != nil {
237+
return nil, xerrors.Errorf("failed to parse roles from claim: %w", err)
238+
}
239+
240+
return parsedRoles, nil
241+
}
242+
45243
type RoleSyncSettings struct {
46244
// Field selects the claim field to be used as the created user's
47245
// groups. If the group field is the empty string, then no group updates
@@ -50,3 +248,11 @@ type RoleSyncSettings struct {
50248
// Mapping maps from an OIDC group --> Coder organization role
51249
Mapping map[string][]string `json:"mapping"`
52250
}
251+
252+
func (s *RoleSyncSettings) Set(v string) error {
253+
return json.Unmarshal([]byte(v), s)
254+
}
255+
256+
func (s *RoleSyncSettings) String() string {
257+
return runtimeconfig.JSONString(s)
258+
}

coderd/runtimeconfig/entry.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func MustNew[T EntryValue](name string) RuntimeEntry[T] {
4646
}
4747

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

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

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

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

8989
// name returns the configured name, or fails with ErrNameNotSet.
90-
func (e *RuntimeEntry[T]) name() (string, error) {
90+
func (e RuntimeEntry[T]) name() (string, error) {
9191
if e.n == "" {
9292
return "", ErrNameNotSet
9393
}

enterprise/coderd/enidpsync/role.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package enidpsync
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/golang-jwt/jwt/v4"
9+
10+
"cdr.dev/slog"
11+
"github.com/coder/coder/v2/coderd/idpsync"
12+
"github.com/coder/coder/v2/codersdk"
13+
)
14+
15+
func (e EnterpriseIDPSync) RoleSyncEnabled() bool {
16+
return e.entitlements.Enabled(codersdk.FeatureUserRoleManagement)
17+
}
18+
19+
func (e EnterpriseIDPSync) ParseRoleClaims(ctx context.Context, mergedClaims jwt.MapClaims) (idpsync.RoleParams, *idpsync.HTTPError) {
20+
if !e.RoleSyncEnabled() {
21+
return e.AGPLIDPSync.ParseRoleClaims(ctx, mergedClaims)
22+
}
23+
24+
var claimRoles []string
25+
if e.AGPLIDPSync.SiteRoleField != "" {
26+
var err error
27+
// TODO: Smoke test this error for org and site
28+
claimRoles, err = e.AGPLIDPSync.RolesFromClaim(e.AGPLIDPSync.SiteRoleField, mergedClaims)
29+
if err != nil {
30+
rawType := mergedClaims[e.AGPLIDPSync.SiteRoleField]
31+
e.Logger.Error(ctx, "oidc claims user roles field was an unknown type",
32+
slog.F("type", fmt.Sprintf("%T", rawType)),
33+
slog.F("field", e.AGPLIDPSync.SiteRoleField),
34+
slog.F("raw_value", rawType),
35+
slog.Error(err),
36+
)
37+
// TODO: Deterine a static page or not
38+
return idpsync.RoleParams{}, &idpsync.HTTPError{
39+
Code: http.StatusInternalServerError,
40+
Msg: "Login disabled until site wide OIDC config is fixed",
41+
Detail: fmt.Sprintf("Roles claim must be an array of strings, type found: %T. Disabling role sync will allow login to proceed.", rawType),
42+
RenderStaticPage: false,
43+
}
44+
}
45+
}
46+
47+
siteRoles := append([]string{}, e.SiteDefaultRoles...)
48+
for _, role := range claimRoles {
49+
if mappedRoles, ok := e.SiteRoleMapping[role]; ok {
50+
if len(mappedRoles) == 0 {
51+
continue
52+
}
53+
// Mapped roles are added to the list of roles
54+
siteRoles = append(siteRoles, mappedRoles...)
55+
continue
56+
}
57+
// Append as is.
58+
siteRoles = append(siteRoles, role)
59+
}
60+
61+
return idpsync.RoleParams{
62+
SyncEnabled: e.RoleSyncEnabled(),
63+
SyncSiteWide: e.AGPLIDPSync.SiteRoleField != "",
64+
SiteWideRoles: siteRoles,
65+
MergedClaims: mergedClaims,
66+
}, nil
67+
}

0 commit comments

Comments
 (0)