Ctrl K
ComunidadGuías…

coderd/idpsync/organization.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package idpsync

import (
	"context"
	"database/sql"
	"encoding/json"

	"github.com/golang-jwt/jwt/v4"
	"github.com/google/uuid"
	"golang.org/x/xerrors"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/coderd/database"
	"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 {
	// 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) OrganizationSyncEntitled() bool {
	// AGPL does not support syncing organizations.
	return false
}

func (AGPLIDPSync) OrganizationSyncEnabled(_ context.Context, _ database.Store) bool {
	return false
}

func (s AGPLIDPSync) UpdateOrganizationSyncSettings(ctx context.Context, db database.Store, settings OrganizationSyncSettings) error {
	rlv := s.Manager.Resolver(db)
	err := s.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) {
	// If this logic is ever updated, make sure to update the corresponding
	// checkIDPOrgSync in coderd/telemetry/telemetry.go.
	rlv := s.Manager.Resolver(db)
	orgSettings, err := s.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.OrganizationField,
			Mapping:       s.OrganizationMapping,
			AssignDefault: s.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{
		SyncEntitled: s.OrganizationSyncEntitled(),
		MergedClaims: claims,
	}, 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.SyncEntitled {
		return nil
	}

	// nolint:gocritic // all syncing is done as a system user
	ctx = dbauthz.AsSystemRestricted(ctx)

	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
	}

	expectedOrgIDs, err := orgSettings.ParseClaims(ctx, tx, params.MergedClaims)
	if err != nil {
		return xerrors.Errorf("organization claims: %w", err)
	}

	// Fetch all organizations, even deleted ones. This is to remove a user
	// from any deleted organizations they may be in.
	existingOrgs, err := tx.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
		UserID:  user.ID,
		Deleted: sql.NullBool{},
	})
	if err != nil {
		return xerrors.Errorf("failed to get user organizations: %w", err)
	}

	existingOrgIDs := slice.List(existingOrgs, func(org database.Organization) uuid.UUID {
		return org.ID
	})

	// finalExpected is the final set of org ids the user is expected to be in.
	// Deleted orgs are omitted from this set.
	finalExpected := expectedOrgIDs
	if len(expectedOrgIDs) > 0 {
		// If you pass in an empty slice to the db arg, you get all orgs. So the slice
		// has to be non-empty to get the expected set. Logically it also does not make
		// sense to fetch an empty set from the db.
		expectedOrganizations, err := tx.GetOrganizations(ctx, database.GetOrganizationsParams{
			IDs: expectedOrgIDs,
			// Do not include deleted organizations. Omitting deleted orgs will remove the
			// user from any deleted organizations they are a member of.
			Deleted: false,
		})
		if err != nil {
			return xerrors.Errorf("failed to get expected organizations: %w", err)
		}
		finalExpected = slice.List(expectedOrganizations, 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, finalExpected)
	// notExists is purely for debugging. It logs when the settings want
	// a user in an organization, but the organization does not exist.
	notExists := slice.DifferenceFunc(expectedOrgIDs, finalExpected, func(a, b uuid.UUID) bool {
		return a == b
	})
	for _, orgID := range add {
		_, err := tx.InsertOrganizationMember(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) {
				// This should not happen because we check the org existence
				// beforehand.
				notExists = append(notExists, orgID)
				continue
			}

			if database.IsUniqueViolation(err, database.UniqueOrganizationMembersPkey) {
				// If we hit this error we have a bug. The user already exists in the
				// organization, but was not detected to be at the start of this function.
				// Instead of failing the function, an error will be logged. This is to not bring
				// down the entire syncing behavior from a single failed org. Failing this can
				// prevent user logins, so only fatal non-recoverable errors should be returned.
				//
				// Inserting a user is privilege escalation. So skipping this instead of failing
				// leaves the user with fewer permissions. So this is safe from a security
				// perspective to continue.
				s.Logger.Error(ctx, "syncing user to organization failed as they are already a member, please report this failure to Coder",
					slog.F("user_id", user.ID),
					slog.F("username", user.Username),
					slog.F("organization_id", orgID),
					slog.Error(err),
				)
				continue
			}
			return xerrors.Errorf("add user to organization: %w", err)
		}
	}

	for _, orgID := range remove {
		err := tx.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{
			OrganizationID: orgID,
			UserID:         user.ID,
		})
		if err != nil {
			return xerrors.Errorf("remove user from organization: %w", err)
		}
	}

	if len(notExists) > 0 {
		notExists = slice.Unique(notExists) // Remove duplicates
		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
}

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 `json:"field"`
	// Mapping controls how organizations returned by the OIDC provider get mapped
	Mapping map[string][]uuid.UUID `json:"mapping"`
	// 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 `json:"assign_default"`
}

func (s *OrganizationSyncSettings) Set(v string) error {
	legacyCheck := make(map[string]any)
	err := json.Unmarshal([]byte(v), &legacyCheck)
	if assign, ok := legacyCheck["AssignDefault"]; err == nil && ok {
		// The legacy JSON key was 'AssignDefault' instead of 'assign_default'
		// Set the default value from the legacy if it exists.
		isBool, ok := assign.(bool)
		if ok {
			s.AssignDefault = isBool
		}
	}

	return json.Unmarshal([]byte(v), s)
}

func (s *OrganizationSyncSettings) String() string {
	if s.Mapping == nil {
		s.Mapping = make(map[string][]uuid.UUID)
	}
	return runtimeconfig.JSONString(s)
}

func (s *OrganizationSyncSettings) MarshalJSON() ([]byte, error) {
	if s.Mapping == nil {
		s.Mapping = make(map[string][]uuid.UUID)
	}

	// Aliasing the struct to avoid infinite recursion when calling json.Marshal
	// on the struct itself.
	type Alias OrganizationSyncSettings
	return json.Marshal(&struct{ *Alias }{Alias: (*Alias)(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
}