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:
Generated
+109
@@ -3166,6 +3166,9 @@ const docTemplate = `{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3182,6 +3185,15 @@ const docTemplate = `{
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "New settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.GroupSyncSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3234,6 +3246,9 @@ const docTemplate = `{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3250,6 +3265,15 @@ const docTemplate = `{
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "New settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.RoleSyncSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3770,6 +3794,68 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/settings/idpsync/organization": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Get organization IdP Sync settings",
|
||||
"operationId": "get-organization-idp-sync-settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.OrganizationSyncSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Update organization IdP Sync settings",
|
||||
"operationId": "update-organization-idp-sync-settings",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "New settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.OrganizationSyncSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.OrganizationSyncSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tailnet": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -11807,6 +11893,29 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.OrganizationSyncSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"description": "Field selects the claim field to be used as the created user's\norganizations. If the field is the empty string, then no organization\nupdates will ever come from the OIDC provider.",
|
||||
"type": "string"
|
||||
},
|
||||
"mapping": {
|
||||
"description": "Mapping maps from an OIDC claim --\u003e Coder organization uuid",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organization_assign_default": {
|
||||
"description": "AssignDefault will ensure the default org is always included\nfor every user, regardless of their claims. This preserves legacy behavior.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PatchGroupRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+95
@@ -2784,6 +2784,7 @@
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Update group IdP Sync settings by organization",
|
||||
@@ -2796,6 +2797,15 @@
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "New settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.GroupSyncSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -2844,6 +2854,7 @@
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Update role IdP Sync settings by organization",
|
||||
@@ -2856,6 +2867,15 @@
|
||||
"name": "organization",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "New settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.RoleSyncSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3316,6 +3336,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/settings/idpsync/organization": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Get organization IdP Sync settings",
|
||||
"operationId": "get-organization-idp-sync-settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.OrganizationSyncSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Update organization IdP Sync settings",
|
||||
"operationId": "update-organization-idp-sync-settings",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "New settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.OrganizationSyncSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.OrganizationSyncSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tailnet": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -10641,6 +10713,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.OrganizationSyncSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"description": "Field selects the claim field to be used as the created user's\norganizations. If the field is the empty string, then no organization\nupdates will ever come from the OIDC provider.",
|
||||
"type": "string"
|
||||
},
|
||||
"mapping": {
|
||||
"description": "Mapping maps from an OIDC claim --\u003e Coder organization uuid",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organization_assign_default": {
|
||||
"description": "AssignDefault will ensure the default org is always included\nfor every user, regardless of their claims. This preserves legacy behavior.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PatchGroupRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -33,9 +33,8 @@ var _ database.Store = (*querier)(nil)
|
||||
|
||||
const wrapname = "dbauthz.querier"
|
||||
|
||||
// NoActorError wraps ErrNoRows for the api to return a 404. This is the correct
|
||||
// response when the user is not authorized.
|
||||
var NoActorError = xerrors.Errorf("no authorization actor in context: %w", sql.ErrNoRows)
|
||||
// NoActorError is returned if no actor is present in the context.
|
||||
var NoActorError = xerrors.Errorf("no authorization actor in context")
|
||||
|
||||
// NotAuthorizedError is a sentinel error that unwraps to sql.ErrNoRows.
|
||||
// This allows the internal error to be read by the caller if needed. Otherwise
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
+24
-6
@@ -45,11 +45,7 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request)
|
||||
aReq.Old = database.AuditableOrganizationMember{}
|
||||
defer commitAudit()
|
||||
|
||||
if user.LoginType == database.LoginTypeOIDC && api.IDPSync.OrganizationSyncEnabled() {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Organization sync is enabled for OIDC users, meaning manual organization assignment is not allowed for this user.",
|
||||
Detail: fmt.Sprintf("User %s is an OIDC user and organization sync is enabled. Ask an administrator to resolve this in your external IDP.", user.ID),
|
||||
})
|
||||
if !api.manualOrganizationMembership(ctx, rw, user) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,6 +112,14 @@ func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request
|
||||
aReq.Old = member.OrganizationMember.Auditable(member.Username)
|
||||
defer commitAudit()
|
||||
|
||||
// Note: we disallow adding OIDC users if organization sync is enabled.
|
||||
// For removing members, do not have this same enforcement. As long as a user
|
||||
// does not re-login, they will not be immediately removed from the organization.
|
||||
// There might be an urgent need to revoke access.
|
||||
// A user can re-login if they are removed in error.
|
||||
// If we add a feature to force logout a user, then we can prevent manual
|
||||
// member removal when organization sync is enabled, and use force logout instead.
|
||||
|
||||
if member.UserID == apiKey.UserID {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "cannot remove self from an organization"})
|
||||
return
|
||||
@@ -272,7 +276,7 @@ func (api *API) allowChangingMemberRoles(ctx context.Context, rw http.ResponseWr
|
||||
}
|
||||
if orgSync {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Cannot modify roles for OIDC users when role sync is enabled. This organization member's roles are managed by the identity provider.",
|
||||
Message: "Cannot modify roles for OIDC users when role sync is enabled. This organization member's roles are managed by the identity provider. Have the user re-login to refresh their roles.",
|
||||
Detail: "'User Role Field' is set in the organization settings. Ask an administrator to adjust or disable these settings.",
|
||||
})
|
||||
return false
|
||||
@@ -372,3 +376,17 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto
|
||||
|
||||
return converted, nil
|
||||
}
|
||||
|
||||
// manualOrganizationMembership checks if the user is an OIDC user and if organization sync is enabled.
|
||||
// If organization sync is enabled, manual organization assignment is not allowed,
|
||||
// since all organization membership is controlled by the external IDP.
|
||||
func (api *API) manualOrganizationMembership(ctx context.Context, rw http.ResponseWriter, user database.User) bool {
|
||||
if user.LoginType == database.LoginTypeOIDC && api.IDPSync.OrganizationSyncEnabled(ctx, api.Database) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Organization sync is enabled for OIDC users, meaning manual organization assignment is not allowed for this user. Have the user re-login to refresh their organizations.",
|
||||
Detail: fmt.Sprintf("User %s is an OIDC user and organization sync is enabled. Ask an administrator to resolve the membership in your external IDP.", user.Username),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -352,6 +352,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
ResourceGroup.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
ResourceGroupMember.Type: {policy.ActionRead},
|
||||
// Manage org membership based on OIDC claims
|
||||
ResourceIdpsyncSettings.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
}),
|
||||
Org: map[string][]Permission{},
|
||||
User: []Permission{},
|
||||
|
||||
@@ -733,10 +733,25 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
|
||||
Resource: rbac.ResourceIdpsyncSettings.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, orgUserAdmin},
|
||||
true: {owner, orgAdmin, orgUserAdmin, userAdmin},
|
||||
false: {
|
||||
orgMemberMe, otherOrgAdmin,
|
||||
memberMe, userAdmin, templateAdmin,
|
||||
memberMe, templateAdmin,
|
||||
orgAuditor, orgTemplateAdmin,
|
||||
otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OrganizationIDPSyncSettings",
|
||||
Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
|
||||
Resource: rbac.ResourceIdpsyncSettings,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, userAdmin},
|
||||
false: {
|
||||
orgAdmin, orgUserAdmin,
|
||||
orgMemberMe, otherOrgAdmin,
|
||||
memberMe, templateAdmin,
|
||||
orgAuditor, orgTemplateAdmin,
|
||||
otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
|
||||
},
|
||||
|
||||
+13
-20
@@ -968,12 +968,10 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
||||
Name: normName,
|
||||
DebugContext: OauthDebugContext{},
|
||||
GroupSync: idpsync.GroupParams{
|
||||
SyncEnabled: false,
|
||||
SyncEntitled: false,
|
||||
},
|
||||
OrganizationSync: idpsync.OrganizationParams{
|
||||
SyncEnabled: false,
|
||||
IncludeDefault: true,
|
||||
Organizations: []uuid.UUID{},
|
||||
SyncEntitled: false,
|
||||
},
|
||||
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
|
||||
return audit.InitRequest[database.User](rw, params)
|
||||
@@ -1513,14 +1511,6 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
// This can happen if a user is a built-in user but is signing in
|
||||
// with OIDC for the first time.
|
||||
if user.ID == uuid.Nil {
|
||||
// Until proper multi-org support, all users will be added to the default organization.
|
||||
// The default organization should always be present.
|
||||
//nolint:gocritic
|
||||
defaultOrganization, err := tx.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to fetch default organization: %w", err)
|
||||
}
|
||||
|
||||
//nolint:gocritic
|
||||
_, err = tx.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
|
||||
Username: params.Username,
|
||||
@@ -1555,19 +1545,22 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
}
|
||||
}
|
||||
|
||||
// Even if org sync is disabled, single org deployments will always
|
||||
// have this set to true.
|
||||
orgIDs := []uuid.UUID{}
|
||||
if params.OrganizationSync.IncludeDefault {
|
||||
orgIDs = append(orgIDs, defaultOrganization.ID)
|
||||
//nolint:gocritic
|
||||
defaultOrganization, err := tx.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to fetch default organization: %w", err)
|
||||
}
|
||||
|
||||
//nolint:gocritic
|
||||
user, err = api.CreateUser(dbauthz.AsSystemRestricted(ctx), tx, CreateUserRequest{
|
||||
CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{
|
||||
Email: params.Email,
|
||||
Username: params.Username,
|
||||
OrganizationIDs: orgIDs,
|
||||
Email: params.Email,
|
||||
Username: params.Username,
|
||||
// This is a kludge, but all users are defaulted into the default
|
||||
// organization. This exists as the default behavior.
|
||||
// If org sync is enabled and configured, the user's groups
|
||||
// will change based on the org sync settings.
|
||||
OrganizationIDs: []uuid.UUID{defaultOrganization.ID},
|
||||
UserStatus: ptr.Ref(codersdk.UserStatusActive),
|
||||
},
|
||||
LoginType: params.LoginType,
|
||||
|
||||
Reference in New Issue
Block a user