mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: implement organization sync and create idpsync package (#14432)
* chore: implement filters for the organizations query * chore: implement organization sync and create idpsync package Organization sync can now be configured to assign users to an org based on oidc claims.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package idpsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/site"
|
||||
)
|
||||
|
||||
// IDPSync is an interface, so we can implement this as AGPL and as enterprise,
|
||||
// and just swap the underlying implementation.
|
||||
// IDPSync exists to contain all the logic for mapping a user's external IDP
|
||||
// claims to the internal representation of a user in Coder.
|
||||
// TODO: Move group + role sync into this interface.
|
||||
type IDPSync interface {
|
||||
OrganizationSyncEnabled() bool
|
||||
// ParseOrganizationClaims takes claims from an OIDC provider, and returns the
|
||||
// organization sync params for assigning users into organizations.
|
||||
ParseOrganizationClaims(ctx context.Context, _ jwt.MapClaims) (OrganizationParams, *HTTPError)
|
||||
// SyncOrganizations assigns and removed users from organizations based on the
|
||||
// provided params.
|
||||
SyncOrganizations(ctx context.Context, tx database.Store, user database.User, params OrganizationParams) error
|
||||
}
|
||||
|
||||
// AGPLIDPSync is the configuration for syncing user information from an external
|
||||
// IDP. All related code to syncing user information should be in this package.
|
||||
type AGPLIDPSync struct {
|
||||
Logger slog.Logger
|
||||
|
||||
SyncSettings
|
||||
}
|
||||
|
||||
type SyncSettings struct {
|
||||
// OrganizationField selects the claim field to be used as the created user's
|
||||
// organizations. If the field is the empty string, then no organization updates
|
||||
// will ever come from the OIDC provider.
|
||||
OrganizationField string
|
||||
// OrganizationMapping controls how organizations returned by the OIDC provider get mapped
|
||||
OrganizationMapping map[string][]uuid.UUID
|
||||
// OrganizationAssignDefault will ensure all users that authenticate will be
|
||||
// placed into the default organization. This is mostly a hack to support
|
||||
// legacy deployments.
|
||||
OrganizationAssignDefault bool
|
||||
}
|
||||
|
||||
type OrganizationParams struct {
|
||||
// SyncEnabled if false will skip syncing the user's organizations.
|
||||
SyncEnabled bool
|
||||
// IncludeDefault is primarily for single org deployments. It will ensure
|
||||
// a user is always inserted into the default org.
|
||||
IncludeDefault bool
|
||||
// Organizations is the list of organizations the user should be a member of
|
||||
// assuming syncing is turned on.
|
||||
Organizations []uuid.UUID
|
||||
}
|
||||
|
||||
func NewAGPLSync(logger slog.Logger, settings SyncSettings) *AGPLIDPSync {
|
||||
return &AGPLIDPSync{
|
||||
Logger: logger.Named("idp-sync"),
|
||||
SyncSettings: settings,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseStringSliceClaim parses the claim for groups and roles, expected []string.
|
||||
//
|
||||
// Some providers like ADFS return a single string instead of an array if there
|
||||
// is only 1 element. So this function handles the edge cases.
|
||||
func ParseStringSliceClaim(claim interface{}) ([]string, error) {
|
||||
groups := make([]string, 0)
|
||||
if claim == nil {
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// The simple case is the type is exactly what we expected
|
||||
asStringArray, ok := claim.([]string)
|
||||
if ok {
|
||||
return asStringArray, nil
|
||||
}
|
||||
|
||||
asArray, ok := claim.([]interface{})
|
||||
if ok {
|
||||
for i, item := range asArray {
|
||||
asString, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("invalid claim type. Element %d expected a string, got: %T", i, item)
|
||||
}
|
||||
groups = append(groups, asString)
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
asString, ok := claim.(string)
|
||||
if ok {
|
||||
if asString == "" {
|
||||
// Empty string should be 0 groups.
|
||||
return []string{}, nil
|
||||
}
|
||||
// If it is a single string, first check if it is a csv.
|
||||
// If a user hits this, it is likely a misconfiguration and they need
|
||||
// to reconfigure their IDP to send an array instead.
|
||||
if strings.Contains(asString, ",") {
|
||||
return nil, xerrors.Errorf("invalid claim type. Got a csv string (%q), change this claim to return an array of strings instead.", asString)
|
||||
}
|
||||
return []string{asString}, nil
|
||||
}
|
||||
|
||||
// Not sure what the user gave us.
|
||||
return nil, xerrors.Errorf("invalid claim type. Expected an array of strings, got: %T", claim)
|
||||
}
|
||||
|
||||
// IsHTTPError handles us being inconsistent with returning errors as values or
|
||||
// pointers.
|
||||
func IsHTTPError(err error) *HTTPError {
|
||||
var httpErr HTTPError
|
||||
if xerrors.As(err, &httpErr) {
|
||||
return &httpErr
|
||||
}
|
||||
|
||||
var httpErrPtr *HTTPError
|
||||
if xerrors.As(err, &httpErrPtr) {
|
||||
return httpErrPtr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTTPError is a helper struct for returning errors from the IDP sync process.
|
||||
// A regular error is not sufficient because many of these errors are surfaced
|
||||
// to a user logging in, and the errors should be descriptive.
|
||||
type HTTPError struct {
|
||||
Code int
|
||||
Msg string
|
||||
Detail string
|
||||
RenderStaticPage bool
|
||||
RenderDetailMarkdown bool
|
||||
}
|
||||
|
||||
func (e HTTPError) Write(rw http.ResponseWriter, r *http.Request) {
|
||||
if e.RenderStaticPage {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: e.Code,
|
||||
HideStatus: true,
|
||||
Title: e.Msg,
|
||||
Description: e.Detail,
|
||||
RetryEnabled: false,
|
||||
DashboardURL: "/login",
|
||||
|
||||
RenderDescriptionMarkdown: e.RenderDetailMarkdown,
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(r.Context(), rw, e.Code, codersdk.Response{
|
||||
Message: e.Msg,
|
||||
Detail: e.Detail,
|
||||
})
|
||||
}
|
||||
|
||||
func (e HTTPError) Error() string {
|
||||
if e.Detail != "" {
|
||||
return e.Detail
|
||||
}
|
||||
|
||||
return e.Msg
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package idpsync_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/idpsync"
|
||||
)
|
||||
|
||||
func TestParseStringSliceClaim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
Name string
|
||||
GoClaim interface{}
|
||||
// JSON Claim allows testing the json -> go conversion
|
||||
// of some strings.
|
||||
JSONClaim string
|
||||
ErrorExpected bool
|
||||
ExpectedSlice []string
|
||||
}{
|
||||
{
|
||||
Name: "Nil",
|
||||
GoClaim: nil,
|
||||
ExpectedSlice: []string{},
|
||||
},
|
||||
// Go Slices
|
||||
{
|
||||
Name: "EmptySlice",
|
||||
GoClaim: []string{},
|
||||
ExpectedSlice: []string{},
|
||||
},
|
||||
{
|
||||
Name: "StringSlice",
|
||||
GoClaim: []string{"a", "b", "c"},
|
||||
ExpectedSlice: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
Name: "InterfaceSlice",
|
||||
GoClaim: []interface{}{"a", "b", "c"},
|
||||
ExpectedSlice: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
Name: "MixedSlice",
|
||||
GoClaim: []interface{}{"a", string("b"), interface{}("c")},
|
||||
ExpectedSlice: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
Name: "StringSliceOneElement",
|
||||
GoClaim: []string{"a"},
|
||||
ExpectedSlice: []string{"a"},
|
||||
},
|
||||
// Json Slices
|
||||
{
|
||||
Name: "JSONEmptySlice",
|
||||
JSONClaim: `[]`,
|
||||
ExpectedSlice: []string{},
|
||||
},
|
||||
{
|
||||
Name: "JSONStringSlice",
|
||||
JSONClaim: `["a", "b", "c"]`,
|
||||
ExpectedSlice: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
Name: "JSONStringSliceOneElement",
|
||||
JSONClaim: `["a"]`,
|
||||
ExpectedSlice: []string{"a"},
|
||||
},
|
||||
// Go string
|
||||
{
|
||||
Name: "String",
|
||||
GoClaim: "a",
|
||||
ExpectedSlice: []string{"a"},
|
||||
},
|
||||
{
|
||||
Name: "EmptyString",
|
||||
GoClaim: "",
|
||||
ExpectedSlice: []string{},
|
||||
},
|
||||
{
|
||||
Name: "Interface",
|
||||
GoClaim: interface{}("a"),
|
||||
ExpectedSlice: []string{"a"},
|
||||
},
|
||||
// JSON string
|
||||
{
|
||||
Name: "JSONString",
|
||||
JSONClaim: `"a"`,
|
||||
ExpectedSlice: []string{"a"},
|
||||
},
|
||||
{
|
||||
Name: "JSONEmptyString",
|
||||
JSONClaim: `""`,
|
||||
ExpectedSlice: []string{},
|
||||
},
|
||||
// Go Errors
|
||||
{
|
||||
Name: "IntegerInSlice",
|
||||
GoClaim: []interface{}{"a", "b", 1},
|
||||
ErrorExpected: true,
|
||||
},
|
||||
// Json Errors
|
||||
{
|
||||
Name: "JSONIntegerInSlice",
|
||||
JSONClaim: `["a", "b", 1]`,
|
||||
ErrorExpected: true,
|
||||
},
|
||||
{
|
||||
Name: "JSON_CSV",
|
||||
JSONClaim: `"a,b,c"`,
|
||||
ErrorExpected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if len(c.JSONClaim) > 0 {
|
||||
require.Nil(t, c.GoClaim, "go claim should be nil if json set")
|
||||
err := json.Unmarshal([]byte(c.JSONClaim), &c.GoClaim)
|
||||
require.NoError(t, err, "unmarshal json claim")
|
||||
}
|
||||
|
||||
found, err := idpsync.ParseStringSliceClaim(c.GoClaim)
|
||||
if c.ErrorExpected {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, c.ExpectedSlice, found, "expected groups")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTTPError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
herr := idpsync.HTTPError{}
|
||||
require.NotNil(t, idpsync.IsHTTPError(herr))
|
||||
require.NotNil(t, idpsync.IsHTTPError(&herr))
|
||||
|
||||
require.Nil(t, error(nil))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package idpsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
)
|
||||
|
||||
func (AGPLIDPSync) OrganizationSyncEnabled() bool {
|
||||
// AGPL does not support syncing organizations.
|
||||
return false
|
||||
}
|
||||
|
||||
func (s AGPLIDPSync) ParseOrganizationClaims(_ context.Context, _ jwt.MapClaims) (OrganizationParams, *HTTPError) {
|
||||
// For AGPL we only sync the default organization.
|
||||
return OrganizationParams{
|
||||
SyncEnabled: s.OrganizationSyncEnabled(),
|
||||
IncludeDefault: s.OrganizationAssignDefault,
|
||||
Organizations: []uuid.UUID{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SyncOrganizations if enabled will ensure the user is a member of the provided
|
||||
// organizations. It will add and remove their membership to match the expected set.
|
||||
func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, user database.User, params OrganizationParams) error {
|
||||
// Nothing happens if sync is not enabled
|
||||
if !params.SyncEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// nolint:gocritic // all syncing is done as a system user
|
||||
ctx = dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
// This is a bit hacky, but if AssignDefault is included, then always
|
||||
// make sure to include the default org in the list of expected.
|
||||
if s.OrganizationAssignDefault {
|
||||
defaultOrg, err := tx.GetDefaultOrganization(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to get default organization: %w", err)
|
||||
}
|
||||
params.Organizations = append(params.Organizations, defaultOrg.ID)
|
||||
}
|
||||
|
||||
existingOrgs, err := tx.GetOrganizationsByUserID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to get user organizations: %w", err)
|
||||
}
|
||||
|
||||
existingOrgIDs := db2sdk.List(existingOrgs, func(org database.Organization) uuid.UUID {
|
||||
return org.ID
|
||||
})
|
||||
|
||||
// Find the difference in the expected and the existing orgs, and
|
||||
// correct the set of orgs the user is a member of.
|
||||
add, remove := slice.SymmetricDifference(existingOrgIDs, params.Organizations)
|
||||
notExists := make([]uuid.UUID, 0)
|
||||
for _, orgID := range add {
|
||||
//nolint:gocritic // System actor being used to assign orgs
|
||||
_, err := tx.InsertOrganizationMember(dbauthz.AsSystemRestricted(ctx), database.InsertOrganizationMemberParams{
|
||||
OrganizationID: orgID,
|
||||
UserID: user.ID,
|
||||
CreatedAt: dbtime.Now(),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
Roles: []string{},
|
||||
})
|
||||
if err != nil {
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
notExists = append(notExists, orgID)
|
||||
continue
|
||||
}
|
||||
return xerrors.Errorf("add user to organization: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, orgID := range remove {
|
||||
//nolint:gocritic // System actor being used to assign orgs
|
||||
err := tx.DeleteOrganizationMember(dbauthz.AsSystemRestricted(ctx), database.DeleteOrganizationMemberParams{
|
||||
OrganizationID: orgID,
|
||||
UserID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("remove user from organization: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(notExists) > 0 {
|
||||
s.Logger.Debug(ctx, "organizations do not exist but attempted to use in org sync",
|
||||
slog.F("not_found", notExists),
|
||||
slog.F("user_id", user.ID),
|
||||
slog.F("username", user.Username),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package idpsync_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/idpsync"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestParseOrganizationClaims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SingleOrgDeployment", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}), idpsync.SyncSettings{
|
||||
OrganizationField: "",
|
||||
OrganizationMapping: nil,
|
||||
OrganizationAssignDefault: true,
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
params, err := s.ParseOrganizationClaims(ctx, jwt.MapClaims{})
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Empty(t, params.Organizations)
|
||||
require.True(t, params.IncludeDefault)
|
||||
require.False(t, params.SyncEnabled)
|
||||
})
|
||||
|
||||
t.Run("AGPL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// AGPL has limited behavior
|
||||
s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}), idpsync.SyncSettings{
|
||||
OrganizationField: "orgs",
|
||||
OrganizationMapping: map[string][]uuid.UUID{
|
||||
"random": {uuid.New()},
|
||||
},
|
||||
OrganizationAssignDefault: false,
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
params, err := s.ParseOrganizationClaims(ctx, jwt.MapClaims{})
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Empty(t, params.Organizations)
|
||||
require.False(t, params.IncludeDefault)
|
||||
require.False(t, params.SyncEnabled)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user