mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
Addresses https://github.com/coder/internal/issues/317. ## Changes Requirements are quoted below: > how many orgs does deployment have Adds the Organization entity to telemetry. > ensuring resources are associated with orgs All resources that reference an org already report the org id to telemetry. Adds a test to check that. > whether org sync is configured Adds the `IDPOrgSync` boolean field to the Deployment entity. ## Implementation of the org sync check While there's an `OrganizationSyncEnabled` method on the IDPSync interface, I decided not to use it directly and implemented a counterpart just for telemetry purposes. It's a compromise I'm not happy about, but I found that it's a simpler approach than the alternative. There are multiple reasons: 1. The telemetry package cannot statically access the IDPSync interface due to a circular import. 2. We can't dynamically pass a reference to the `OrganizationSyncEnabled` function at the time of instantiating the telemetry object, because our server initialization logic depends on the telemetry object being created before the IDPSync object. 3. If we circumvent that problem by passing the reference as an initially empty pointer, initializing telemetry, then IDPSync, then updating the pointer to point to `OrganizationSyncEnabled`, we have to refactor the initialization logic of the telemetry object itself to avoid a race condition where the first telemetry report is performed without a valid reference. I actually implemented that approach in https://github.com/coder/coder/pull/16307, but realized I'm unable to fully test it. It changed the initialization order in the server command, and I wanted to test our CLI with Org Sync configured with a premium license. As far as I'm aware, we don't have the tooling to do that. I couldn't figure out a way to start the CLI with a mock license, and I didn't want to go down further into the refactoring rabbit hole. So I decided that reimplementing the org sync checking logic is simpler.
210 lines
6.7 KiB
Go
210 lines
6.7 KiB
Go
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"
|
|
"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/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) 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) {
|
|
// 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.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{
|
|
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
|
|
}
|
|
|
|
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)
|
|
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, expectedOrgs)
|
|
notExists := make([]uuid.UUID, 0)
|
|
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) {
|
|
notExists = append(notExists, orgID)
|
|
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 {
|
|
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 {
|
|
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
|
|
}
|