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:
Steven Masley
2024-11-08 08:44:14 -06:00
committed by GitHub
parent 7b33ab0dcf
commit 782214bcd8
28 changed files with 883 additions and 280 deletions
+5 -5
View File
@@ -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
}
+5 -5
View File
@@ -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"},
},
+12 -5
View File
@@ -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
View File
@@ -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
}
+1 -24
View File
@@ -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)
})
}