Skip to content

Commit b9abffe

Browse files
committed
chore: implement organization sync and create idpsync package
IDP sync code should be refactored to be contained in it's own package. This will make it easier to maintain and test moving forward.
1 parent 6914862 commit b9abffe

File tree

14 files changed

+339
-74
lines changed

14 files changed

+339
-74
lines changed

coderd/database/dbauthz/dbauthz.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,9 +1700,9 @@ func (q *querier) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.
17001700
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOrganizationIDsByMemberIDs)(ctx, ids)
17011701
}
17021702

1703-
func (q *querier) GetOrganizations(ctx context.Context) ([]database.Organization, error) {
1703+
func (q *querier) GetOrganizations(ctx context.Context, args database.GetOrganizationsParams) ([]database.Organization, error) {
17041704
fetch := func(ctx context.Context, _ interface{}) ([]database.Organization, error) {
1705-
return q.db.GetOrganizations(ctx)
1705+
return q.db.GetOrganizations(ctx, args)
17061706
}
17071707
return fetchWithPostFilter(q.auth, policy.ActionRead, fetch)(ctx, nil)
17081708
}

coderd/database/dbauthz/dbauthz_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,7 @@ func (s *MethodTestSuite) TestOrganization() {
635635
def, _ := db.GetDefaultOrganization(context.Background())
636636
a := dbgen.Organization(s.T(), db, database.Organization{})
637637
b := dbgen.Organization(s.T(), db, database.Organization{})
638-
check.Args().Asserts(def, policy.ActionRead, a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(def, a, b))
638+
check.Args(database.GetOrganizationsParams{}).Asserts(def, policy.ActionRead, a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(def, a, b))
639639
}))
640640
s.Run("GetOrganizationsByUserID", s.Subtest(func(db database.Store, check *expects) {
641641
u := dbgen.User(s.T(), db, database.User{})

coderd/database/dbmem/dbmem.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3034,14 +3034,24 @@ func (q *FakeQuerier) GetOrganizationIDsByMemberIDs(_ context.Context, ids []uui
30343034
return getOrganizationIDsByMemberIDRows, nil
30353035
}
30363036

3037-
func (q *FakeQuerier) GetOrganizations(_ context.Context) ([]database.Organization, error) {
3037+
func (q *FakeQuerier) GetOrganizations(_ context.Context, args database.GetOrganizationsParams) ([]database.Organization, error) {
30383038
q.mutex.RLock()
30393039
defer q.mutex.RUnlock()
30403040

3041-
if len(q.organizations) == 0 {
3042-
return nil, sql.ErrNoRows
3041+
tmp := make([]database.Organization, 0)
3042+
for _, org := range q.organizations {
3043+
if len(args.IDs) > 0 {
3044+
if !slices.Contains(args.IDs, org.ID) {
3045+
continue
3046+
}
3047+
}
3048+
if args.Name != "" && !strings.EqualFold(org.Name, args.Name) {
3049+
continue
3050+
}
3051+
tmp = append(tmp, org)
30433052
}
3044-
return q.organizations, nil
3053+
3054+
return tmp, nil
30453055
}
30463056

30473057
func (q *FakeQuerier) GetOrganizationsByUserID(_ context.Context, userID uuid.UUID) ([]database.Organization, error) {

coderd/database/dbmetrics/dbmetrics.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbmock/dbmock.go

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/models.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/querier.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 21 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/organizations.sql

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,21 @@ LIMIT
1212
SELECT
1313
*
1414
FROM
15-
organizations;
15+
organizations
16+
WHERE
17+
true
18+
-- Filter by ids
19+
AND CASE
20+
WHEN array_length(@ids :: uuid[], 1) > 0 THEN
21+
id = ANY(@ids)
22+
ELSE true
23+
END
24+
AND CASE
25+
WHEN @name::text != '' THEN
26+
LOWER("name") = LOWER(@name)
27+
ELSE true
28+
END
29+
;
1630

1731
-- name: GetOrganizationByID :one
1832
SELECT

coderd/idpsync/idpsync.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package idpsync
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
7+
"github.com/google/uuid"
8+
"golang.org/x/xerrors"
9+
10+
"cdr.dev/slog"
11+
"github.com/coder/coder/v2/coderd/entitlements"
12+
"github.com/coder/coder/v2/coderd/httpapi"
13+
"github.com/coder/coder/v2/codersdk"
14+
"github.com/coder/coder/v2/site"
15+
)
16+
17+
// IDPSync is the configuration for syncing user information from an external
18+
// IDP. All related code to syncing user information should be in this package.
19+
type IDPSync struct {
20+
logger slog.Logger
21+
entitlements *entitlements.Set
22+
23+
// OrganizationField selects the claim field to be used as the created user's
24+
// organizations. If the field is the empty string, then no organization updates
25+
// will ever come from the OIDC provider.
26+
OrganizationField string
27+
// OrganizationMapping controls how organizations returned by the OIDC provider get mapped
28+
OrganizationMapping map[string][]uuid.UUID
29+
// OrganizationAlwaysAssign will ensure all users that authenticate will be
30+
// placed into the specified organizations.
31+
OrganizationAlwaysAssign []uuid.UUID
32+
}
33+
34+
func NewSync(logger slog.Logger, set *entitlements.Set) *IDPSync {
35+
return &IDPSync{
36+
logger: logger.Named("idp-sync"),
37+
entitlements: set,
38+
}
39+
}
40+
41+
// ParseStringSliceClaim parses the claim for groups and roles, expected []string.
42+
//
43+
// Some providers like ADFS return a single string instead of an array if there
44+
// is only 1 element. So this function handles the edge cases.
45+
func ParseStringSliceClaim(claim interface{}) ([]string, error) {
46+
groups := make([]string, 0)
47+
if claim == nil {
48+
return groups, nil
49+
}
50+
51+
// The simple case is the type is exactly what we expected
52+
asStringArray, ok := claim.([]string)
53+
if ok {
54+
return asStringArray, nil
55+
}
56+
57+
asArray, ok := claim.([]interface{})
58+
if ok {
59+
for i, item := range asArray {
60+
asString, ok := item.(string)
61+
if !ok {
62+
return nil, xerrors.Errorf("invalid claim type. Element %d expected a string, got: %T", i, item)
63+
}
64+
groups = append(groups, asString)
65+
}
66+
return groups, nil
67+
}
68+
69+
asString, ok := claim.(string)
70+
if ok {
71+
if asString == "" {
72+
// Empty string should be 0 groups.
73+
return []string{}, nil
74+
}
75+
// If it is a single string, first check if it is a csv.
76+
// If a user hits this, it is likely a misconfiguration and they need
77+
// to reconfigure their IDP to send an array instead.
78+
if strings.Contains(asString, ",") {
79+
return nil, xerrors.Errorf("invalid claim type. Got a csv string (%q), change this claim to return an array of strings instead.", asString)
80+
}
81+
return []string{asString}, nil
82+
}
83+
84+
// Not sure what the user gave us.
85+
return nil, xerrors.Errorf("invalid claim type. Expected an array of strings, got: %T", claim)
86+
}
87+
88+
// HttpError is a helper struct for returning errors from the IDP sync process.
89+
// A regular error is not sufficient because many of these errors are surfaced
90+
// to a user logging in, and the errors should be descriptive.
91+
type HttpError struct {
92+
Code int
93+
Msg string
94+
Detail string
95+
RenderStaticPage bool
96+
RenderDetailMarkdown bool
97+
}
98+
99+
func (e HttpError) Write(rw http.ResponseWriter, r *http.Request) {
100+
if e.RenderStaticPage {
101+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
102+
Status: e.Code,
103+
HideStatus: true,
104+
Title: e.Msg,
105+
Description: e.Detail,
106+
RetryEnabled: false,
107+
DashboardURL: "/login",
108+
109+
RenderDescriptionMarkdown: e.RenderDetailMarkdown,
110+
})
111+
return
112+
}
113+
httpapi.Write(r.Context(), rw, e.Code, codersdk.Response{
114+
Message: e.Msg,
115+
Detail: e.Detail,
116+
})
117+
}
118+
119+
func (e HttpError) Error() string {
120+
if e.Detail != "" {
121+
return e.Detail
122+
}
123+
124+
return e.Msg
125+
}

coderd/userauth_internal_test.go renamed to coderd/idpsync/idpsync_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
package coderd
1+
package idpsync_test
22

33
import (
44
"encoding/json"
55
"testing"
66

77
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/v2/coderd/idpsync"
810
)
911

1012
func TestParseStringSliceClaim(t *testing.T) {
@@ -123,7 +125,7 @@ func TestParseStringSliceClaim(t *testing.T) {
123125
require.NoError(t, err, "unmarshal json claim")
124126
}
125127

126-
found, err := parseStringSliceClaim(c.GoClaim)
128+
found, err := idpsync.ParseStringSliceClaim(c.GoClaim)
127129
if c.ErrorExpected {
128130
require.Error(t, err)
129131
} else {

0 commit comments

Comments
 (0)