mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: move organizatinon sync to runtime configuration (#15431)
Moves the configuration from environment to database backed, to allow configuring organization sync at runtime.
This commit is contained in:
@@ -20,12 +20,12 @@ import (
|
||||
)
|
||||
|
||||
type GroupParams struct {
|
||||
// SyncEnabled if false will skip syncing the user's groups
|
||||
SyncEnabled bool
|
||||
// SyncEntitled if false will skip syncing the user's groups
|
||||
SyncEntitled bool
|
||||
MergedClaims jwt.MapClaims
|
||||
}
|
||||
|
||||
func (AGPLIDPSync) GroupSyncEnabled() bool {
|
||||
func (AGPLIDPSync) GroupSyncEntitled() bool {
|
||||
// AGPL does not support syncing groups.
|
||||
return false
|
||||
}
|
||||
@@ -73,13 +73,13 @@ func (s AGPLIDPSync) GroupSyncSettings(ctx context.Context, orgID uuid.UUID, db
|
||||
|
||||
func (s AGPLIDPSync) ParseGroupClaims(_ context.Context, _ jwt.MapClaims) (GroupParams, *HTTPError) {
|
||||
return GroupParams{
|
||||
SyncEnabled: s.GroupSyncEnabled(),
|
||||
SyncEntitled: s.GroupSyncEntitled(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s AGPLIDPSync) SyncGroups(ctx context.Context, db database.Store, user database.User, params GroupParams) error {
|
||||
// Nothing happens if sync is not enabled
|
||||
if !params.SyncEnabled {
|
||||
if !params.SyncEntitled {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestParseGroupClaims(t *testing.T) {
|
||||
params, err := s.ParseGroupClaims(ctx, jwt.MapClaims{})
|
||||
require.Nil(t, err)
|
||||
|
||||
require.False(t, params.SyncEnabled)
|
||||
require.False(t, params.SyncEntitled)
|
||||
})
|
||||
|
||||
// AllowList has no effect in AGPL
|
||||
@@ -61,7 +61,7 @@ func TestParseGroupClaims(t *testing.T) {
|
||||
|
||||
params, err := s.ParseGroupClaims(ctx, jwt.MapClaims{})
|
||||
require.Nil(t, err)
|
||||
require.False(t, params.SyncEnabled)
|
||||
require.False(t, params.SyncEntitled)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestGroupSyncTable(t *testing.T) {
|
||||
|
||||
// Do the group sync!
|
||||
err := s.SyncGroups(ctx, db, user, idpsync.GroupParams{
|
||||
SyncEnabled: true,
|
||||
SyncEntitled: true,
|
||||
MergedClaims: userClaims,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -363,7 +363,7 @@ func TestGroupSyncTable(t *testing.T) {
|
||||
|
||||
// Do the group sync!
|
||||
err = s.SyncGroups(ctx, db, user, idpsync.GroupParams{
|
||||
SyncEnabled: true,
|
||||
SyncEntitled: true,
|
||||
MergedClaims: userClaims,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -420,7 +420,7 @@ func TestSyncDisabled(t *testing.T) {
|
||||
|
||||
// Do the group sync!
|
||||
err := s.SyncGroups(ctx, db, user, idpsync.GroupParams{
|
||||
SyncEnabled: false,
|
||||
SyncEntitled: false,
|
||||
MergedClaims: jwt.MapClaims{
|
||||
"groups": []string{"baz", "bop"},
|
||||
},
|
||||
|
||||
@@ -24,8 +24,13 @@ import (
|
||||
// claims to the internal representation of a user in Coder.
|
||||
// TODO: Move group + role sync into this interface.
|
||||
type IDPSync interface {
|
||||
AssignDefaultOrganization() bool
|
||||
OrganizationSyncEnabled() bool
|
||||
OrganizationSyncEntitled() bool
|
||||
OrganizationSyncSettings(ctx context.Context, db database.Store) (*OrganizationSyncSettings, error)
|
||||
UpdateOrganizationSettings(ctx context.Context, db database.Store, settings OrganizationSyncSettings) error
|
||||
// OrganizationSyncEnabled returns true if all OIDC users are assigned
|
||||
// to organizations via org sync settings.
|
||||
// This is used to know when to disable manual org membership assignment.
|
||||
OrganizationSyncEnabled(ctx context.Context, db database.Store) bool
|
||||
// ParseOrganizationClaims takes claims from an OIDC provider, and returns the
|
||||
// organization sync params for assigning users into organizations.
|
||||
ParseOrganizationClaims(ctx context.Context, mergedClaims jwt.MapClaims) (OrganizationParams, *HTTPError)
|
||||
@@ -33,7 +38,7 @@ type IDPSync interface {
|
||||
// provided params.
|
||||
SyncOrganizations(ctx context.Context, tx database.Store, user database.User, params OrganizationParams) error
|
||||
|
||||
GroupSyncEnabled() bool
|
||||
GroupSyncEntitled() bool
|
||||
// ParseGroupClaims takes claims from an OIDC provider, and returns the params
|
||||
// for group syncing. Most of the logic happens in SyncGroups.
|
||||
ParseGroupClaims(ctx context.Context, mergedClaims jwt.MapClaims) (GroupParams, *HTTPError)
|
||||
@@ -147,8 +152,9 @@ func FromDeploymentValues(dv *codersdk.DeploymentValues) DeploymentSyncSettings
|
||||
type SyncSettings struct {
|
||||
DeploymentSyncSettings
|
||||
|
||||
Group runtimeconfig.RuntimeEntry[*GroupSyncSettings]
|
||||
Role runtimeconfig.RuntimeEntry[*RoleSyncSettings]
|
||||
Group runtimeconfig.RuntimeEntry[*GroupSyncSettings]
|
||||
Role runtimeconfig.RuntimeEntry[*RoleSyncSettings]
|
||||
Organization runtimeconfig.RuntimeEntry[*OrganizationSyncSettings]
|
||||
}
|
||||
|
||||
func NewAGPLSync(logger slog.Logger, manager *runtimeconfig.Manager, settings DeploymentSyncSettings) *AGPLIDPSync {
|
||||
@@ -159,6 +165,7 @@ func NewAGPLSync(logger slog.Logger, manager *runtimeconfig.Manager, settings De
|
||||
DeploymentSyncSettings: settings,
|
||||
Group: runtimeconfig.MustNew[*GroupSyncSettings]("group-sync-settings"),
|
||||
Role: runtimeconfig.MustNew[*RoleSyncSettings]("role-sync-settings"),
|
||||
Organization: runtimeconfig.MustNew[*OrganizationSyncSettings]("organization-sync-settings"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+117
-29
@@ -3,6 +3,7 @@ package idpsync
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
@@ -13,35 +14,59 @@ import (
|
||||
"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/runtimeconfig"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
)
|
||||
|
||||
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
|
||||
// SyncEntitled if false will skip syncing the user's organizations.
|
||||
SyncEntitled bool
|
||||
// MergedClaims are passed to the organization level for syncing
|
||||
MergedClaims jwt.MapClaims
|
||||
}
|
||||
|
||||
func (AGPLIDPSync) OrganizationSyncEnabled() bool {
|
||||
func (AGPLIDPSync) OrganizationSyncEntitled() bool {
|
||||
// AGPL does not support syncing organizations.
|
||||
return false
|
||||
}
|
||||
|
||||
func (s AGPLIDPSync) AssignDefaultOrganization() bool {
|
||||
return s.OrganizationAssignDefault
|
||||
func (AGPLIDPSync) OrganizationSyncEnabled(_ context.Context, _ database.Store) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s AGPLIDPSync) ParseOrganizationClaims(_ context.Context, _ jwt.MapClaims) (OrganizationParams, *HTTPError) {
|
||||
func (s AGPLIDPSync) UpdateOrganizationSettings(ctx context.Context, db database.Store, settings OrganizationSyncSettings) error {
|
||||
rlv := s.Manager.Resolver(db)
|
||||
err := s.SyncSettings.Organization.SetRuntimeValue(ctx, rlv, &settings)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update organization sync settings: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s AGPLIDPSync) OrganizationSyncSettings(ctx context.Context, db database.Store) (*OrganizationSyncSettings, error) {
|
||||
rlv := s.Manager.Resolver(db)
|
||||
orgSettings, err := s.SyncSettings.Organization.Resolve(ctx, rlv)
|
||||
if err != nil {
|
||||
if !xerrors.Is(err, runtimeconfig.ErrEntryNotFound) {
|
||||
return nil, xerrors.Errorf("resolve org sync settings: %w", err)
|
||||
}
|
||||
|
||||
// Default to the statically assigned settings if they exist.
|
||||
orgSettings = &OrganizationSyncSettings{
|
||||
Field: s.DeploymentSyncSettings.OrganizationField,
|
||||
Mapping: s.DeploymentSyncSettings.OrganizationMapping,
|
||||
AssignDefault: s.DeploymentSyncSettings.OrganizationAssignDefault,
|
||||
}
|
||||
}
|
||||
return orgSettings, nil
|
||||
}
|
||||
|
||||
func (s AGPLIDPSync) ParseOrganizationClaims(_ context.Context, claims jwt.MapClaims) (OrganizationParams, *HTTPError) {
|
||||
// For AGPL we only sync the default organization.
|
||||
return OrganizationParams{
|
||||
SyncEnabled: s.OrganizationSyncEnabled(),
|
||||
IncludeDefault: s.OrganizationAssignDefault,
|
||||
Organizations: []uuid.UUID{},
|
||||
SyncEntitled: s.OrganizationSyncEntitled(),
|
||||
MergedClaims: claims,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -49,21 +74,25 @@ func (s AGPLIDPSync) ParseOrganizationClaims(_ context.Context, _ jwt.MapClaims)
|
||||
// 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 {
|
||||
if !params.SyncEntitled {
|
||||
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)
|
||||
orgSettings, err := s.OrganizationSyncSettings(ctx, tx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to get org sync settings: %w", err)
|
||||
}
|
||||
|
||||
if orgSettings.Field == "" {
|
||||
return nil // No sync configured, nothing to do
|
||||
}
|
||||
|
||||
expectedOrgs, err := orgSettings.ParseClaims(ctx, tx, params.MergedClaims)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("organization claims: %w", err)
|
||||
}
|
||||
|
||||
existingOrgs, err := tx.GetOrganizationsByUserID(ctx, user.ID)
|
||||
@@ -77,11 +106,10 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u
|
||||
|
||||
// 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)
|
||||
add, remove := slice.SymmetricDifference(existingOrgIDs, expectedOrgs)
|
||||
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{
|
||||
_, err := tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{
|
||||
OrganizationID: orgID,
|
||||
UserID: user.ID,
|
||||
CreatedAt: dbtime.Now(),
|
||||
@@ -98,8 +126,7 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u
|
||||
}
|
||||
|
||||
for _, orgID := range remove {
|
||||
//nolint:gocritic // System actor being used to assign orgs
|
||||
err := tx.DeleteOrganizationMember(dbauthz.AsSystemRestricted(ctx), database.DeleteOrganizationMemberParams{
|
||||
err := tx.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{
|
||||
OrganizationID: orgID,
|
||||
UserID: user.ID,
|
||||
})
|
||||
@@ -117,3 +144,64 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type OrganizationSyncSettings struct {
|
||||
// Field 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.
|
||||
Field string
|
||||
// Mapping controls how organizations returned by the OIDC provider get mapped
|
||||
Mapping map[string][]uuid.UUID
|
||||
// AssignDefault will ensure all users that authenticate will be
|
||||
// placed into the default organization. This is mostly a hack to support
|
||||
// legacy deployments.
|
||||
AssignDefault bool
|
||||
}
|
||||
|
||||
func (s *OrganizationSyncSettings) Set(v string) error {
|
||||
return json.Unmarshal([]byte(v), s)
|
||||
}
|
||||
|
||||
func (s *OrganizationSyncSettings) String() string {
|
||||
return runtimeconfig.JSONString(s)
|
||||
}
|
||||
|
||||
// ParseClaims will parse the claims and return the list of organizations the user
|
||||
// should sync to.
|
||||
func (s *OrganizationSyncSettings) ParseClaims(ctx context.Context, db database.Store, mergedClaims jwt.MapClaims) ([]uuid.UUID, error) {
|
||||
userOrganizations := make([]uuid.UUID, 0)
|
||||
|
||||
if s.AssignDefault {
|
||||
// This is a bit hacky, but if AssignDefault is included, then always
|
||||
// make sure to include the default org in the list of expected.
|
||||
defaultOrg, err := db.GetDefaultOrganization(ctx)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to get default organization: %w", err)
|
||||
}
|
||||
|
||||
// Always include default org.
|
||||
userOrganizations = append(userOrganizations, defaultOrg.ID)
|
||||
}
|
||||
|
||||
organizationRaw, ok := mergedClaims[s.Field]
|
||||
if !ok {
|
||||
return userOrganizations, nil
|
||||
}
|
||||
|
||||
parsedOrganizations, err := ParseStringSliceClaim(organizationRaw)
|
||||
if err != nil {
|
||||
return userOrganizations, xerrors.Errorf("failed to parese organizations OIDC claims: %w", err)
|
||||
}
|
||||
|
||||
// add any mapped organizations
|
||||
for _, parsedOrg := range parsedOrganizations {
|
||||
if mappedOrganization, ok := s.Mapping[parsedOrg]; ok {
|
||||
// parsedOrg is in the mapping, so add the mapped organizations to the
|
||||
// user's organizations.
|
||||
userOrganizations = append(userOrganizations, mappedOrganization...)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate the organizations
|
||||
return slice.Unique(userOrganizations), nil
|
||||
}
|
||||
|
||||
@@ -16,27 +16,6 @@ import (
|
||||
func TestParseOrganizationClaims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SingleOrgDeployment", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}),
|
||||
runtimeconfig.NewManager(),
|
||||
idpsync.DeploymentSyncSettings{
|
||||
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()
|
||||
|
||||
@@ -56,8 +35,6 @@ func TestParseOrganizationClaims(t *testing.T) {
|
||||
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)
|
||||
require.False(t, params.SyncEntitled)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user