From 6046b56de521b9affdca248a1ca084352997038d Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 10:44:01 -0500 Subject: [PATCH 01/12] test: add unit test to excercise bug when idp sync hits deleted orgs Deleted organizations are still attempting to sync members. This causes an error on inserting the member, and would likely cause issues later in the sync process even if that member is inserted. Deleted orgs should be skipped. --- coderd/idpsync/organizations_test.go | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/coderd/idpsync/organizations_test.go b/coderd/idpsync/organizations_test.go index 51c8a7365d22b..868781b763c4b 100644 --- a/coderd/idpsync/organizations_test.go +++ b/coderd/idpsync/organizations_test.go @@ -8,6 +8,10 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/idpsync" "github.com/coder/coder/v2/coderd/runtimeconfig" "github.com/coder/coder/v2/testutil" @@ -38,3 +42,59 @@ func TestParseOrganizationClaims(t *testing.T) { require.False(t, params.SyncEntitled) }) } + +func TestSyncOrganizations(t *testing.T) { + t.Parallel() + + t.Run("SyncUserToDeletedOrg", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitMedium) + db, _ := dbtestutil.NewDB(t) + + // Create a new organization, add in the user as a member, then delete + // the org. + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Roles: nil, + }) + + err := db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + UpdatedAt: dbtime.Now(), + ID: org.ID, + }) + require.NoError(t, err) + + // Now sync the user to the deleted organization + s := idpsync.NewAGPLSync( + slogtest.Make(t, &slogtest.Options{}), + runtimeconfig.NewManager(), + idpsync.DeploymentSyncSettings{ + OrganizationField: "orgs", + OrganizationMapping: map[string][]uuid.UUID{ + "random": {org.ID}, + }, + OrganizationAssignDefault: false, + }, + ) + + err = s.SyncOrganizations(ctx, db, user, idpsync.OrganizationParams{ + SyncEntitled: true, + MergedClaims: map[string]interface{}{ + "orgs": []string{"random"}, + }, + }) + require.NoError(t, err) + + mems, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{ + OrganizationID: org.ID, + UserID: user.ID, + IncludeSystem: false, + }) + require.NoError(t, err) + require.Len(t, mems, 1) + }) +} From c1cc257bdde64531e75254b6dcaee5b38ba5c2aa Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 11:13:06 -0500 Subject: [PATCH 02/12] feat: remove users from deleted organizations in idp org sync Idp sync should exclude deleted organizations and auto remove members. They should not be members in the first place. --- coderd/database/dbmem/dbmem.go | 6 +++++- coderd/database/queries.sql.go | 11 ++++++++--- coderd/database/queries/organizations.sql | 9 +++++++-- coderd/idpsync/organization.go | 21 ++++++++++++++++++--- coderd/users.go | 2 +- 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index ed9f098c00e3c..85c9248d28787 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -4172,7 +4172,11 @@ func (q *FakeQuerier) GetOrganizationsByUserID(_ context.Context, arg database.G continue } for _, organization := range q.organizations { - if organization.ID != organizationMember.OrganizationID || organization.Deleted != arg.Deleted { + if organization.ID != organizationMember.OrganizationID { + continue + } + + if arg.Deleted.Valid && organization.Deleted != arg.Deleted.Bool { continue } organizations = append(organizations, organization) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c1738589d37ae..83bb8b2590c97 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5681,7 +5681,12 @@ FROM organizations WHERE -- Optionally include deleted organizations - deleted = $2 AND + CASE WHEN + $2 :: boolean IS NULL THEN + true + ELSE + deleted = $2 + END AND id = ANY( SELECT organization_id @@ -5693,8 +5698,8 @@ WHERE ` type GetOrganizationsByUserIDParams struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - Deleted bool `db:"deleted" json:"deleted"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Deleted sql.NullBool `db:"deleted" json:"deleted"` } func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, arg GetOrganizationsByUserIDParams) ([]Organization, error) { diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index d710a26ca9a46..d940fb1ad4dc6 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -55,8 +55,13 @@ SELECT FROM organizations WHERE - -- Optionally include deleted organizations - deleted = @deleted AND + -- Optionally provide a filter for deleted organizations. + CASE WHEN + sqlc.narg('deleted') :: boolean IS NULL THEN + true + ELSE + deleted = sqlc.narg('deleted') + END AND id = ANY( SELECT organization_id diff --git a/coderd/idpsync/organization.go b/coderd/idpsync/organization.go index 87fd9af5e935d..f2a043f60a731 100644 --- a/coderd/idpsync/organization.go +++ b/coderd/idpsync/organization.go @@ -92,14 +92,16 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u return nil // No sync configured, nothing to do } - expectedOrgs, err := orgSettings.ParseClaims(ctx, tx, params.MergedClaims) + 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: false, + Deleted: sql.NullBool{}, }) if err != nil { return xerrors.Errorf("failed to get user organizations: %w", err) @@ -109,9 +111,22 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u return org.ID }) + expectedOrganizations, err := tx.GetOrganizations(ctx, database.GetOrganizationsParams{ + IDs: expectedOrgIDs, + // Do not include deleted organizations + Deleted: false, + }) + if err != nil { + return xerrors.Errorf("failed to get expected organizations: %w", err) + } + + finalExpected := db2sdk.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, expectedOrgs) + add, remove := slice.SymmetricDifference(existingOrgIDs, finalExpected) notExists := make([]uuid.UUID, 0) for _, orgID := range add { _, err := tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ diff --git a/coderd/users.go b/coderd/users.go index d97abc82b2fd1..ad1ba8a018743 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1340,7 +1340,7 @@ func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) { organizations, err := api.Database.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ UserID: user.ID, - Deleted: false, + Deleted: sql.NullBool{Bool: false, Valid: true}, }) if errors.Is(err, sql.ErrNoRows) { err = nil From c267137f49d03de3c7faaa8282fa0816c01450bb Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 11:27:35 -0500 Subject: [PATCH 03/12] add some test noise --- coderd/idpsync/organization.go | 24 +++++++++++++++++++++++- coderd/idpsync/organizations_test.go | 13 ++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/coderd/idpsync/organization.go b/coderd/idpsync/organization.go index f2a043f60a731..8e90221679a44 100644 --- a/coderd/idpsync/organization.go +++ b/coderd/idpsync/organization.go @@ -127,7 +127,11 @@ 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, finalExpected) - notExists := make([]uuid.UUID, 0) + // 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, @@ -138,9 +142,26 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u }) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { + // This should not happen because we check the org existance + // 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. + 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) } } @@ -156,6 +177,7 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u } if len(notExists) > 0 { + notExists = slice.Unique(notExists) // Remove dupes 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), diff --git a/coderd/idpsync/organizations_test.go b/coderd/idpsync/organizations_test.go index 868781b763c4b..360b3df0eb72a 100644 --- a/coderd/idpsync/organizations_test.go +++ b/coderd/idpsync/organizations_test.go @@ -49,17 +49,19 @@ func TestSyncOrganizations(t *testing.T) { t.Run("SyncUserToDeletedOrg", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + extra := dbgen.Organization(t, db, database.Organization{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: extra.ID, + }) // Create a new organization, add in the user as a member, then delete // the org. org := dbgen.Organization(t, db, database.Organization{}) - user := dbgen.User(t, db, database.User{}) dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: user.ID, OrganizationID: org.ID, - CreatedAt: dbtime.Now(), - UpdatedAt: dbtime.Now(), - Roles: nil, }) err := db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ @@ -76,6 +78,7 @@ func TestSyncOrganizations(t *testing.T) { OrganizationField: "orgs", OrganizationMapping: map[string][]uuid.UUID{ "random": {org.ID}, + "noise": {uuid.New()}, }, OrganizationAssignDefault: false, }, @@ -84,7 +87,7 @@ func TestSyncOrganizations(t *testing.T) { err = s.SyncOrganizations(ctx, db, user, idpsync.OrganizationParams{ SyncEntitled: true, MergedClaims: map[string]interface{}{ - "orgs": []string{"random"}, + "orgs": []string{"random", "noise"}, }, }) require.NoError(t, err) From a9a9cfa1b7fed6fa22b698a2d16ac2ca6db61bf7 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 12:51:38 -0500 Subject: [PATCH 04/12] gen + lint --- coderd/database/queries.sql.go | 2 +- coderd/idpsync/organization.go | 28 ++++++++++++++++------------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 83bb8b2590c97..72f2c4a8fcb8e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5680,7 +5680,7 @@ SELECT FROM organizations WHERE - -- Optionally include deleted organizations + -- Optionally provide a filter for deleted organizations. CASE WHEN $2 :: boolean IS NULL THEN true diff --git a/coderd/idpsync/organization.go b/coderd/idpsync/organization.go index 8e90221679a44..84b38e36c60ff 100644 --- a/coderd/idpsync/organization.go +++ b/coderd/idpsync/organization.go @@ -111,19 +111,23 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u return org.ID }) - expectedOrganizations, err := tx.GetOrganizations(ctx, database.GetOrganizationsParams{ - IDs: expectedOrgIDs, - // Do not include deleted organizations - Deleted: false, - }) - if err != nil { - return xerrors.Errorf("failed to get expected organizations: %w", err) + // 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 { + expectedOrganizations, err := tx.GetOrganizations(ctx, database.GetOrganizationsParams{ + IDs: expectedOrgIDs, + // Do not include deleted organizations + Deleted: false, + }) + if err != nil { + return xerrors.Errorf("failed to get expected organizations: %w", err) + } + finalExpected = db2sdk.List(expectedOrganizations, func(org database.Organization) uuid.UUID { + return org.ID + }) } - finalExpected := db2sdk.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) @@ -142,7 +146,7 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u }) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { - // This should not happen because we check the org existance + // This should not happen because we check the org existence // beforehand. notExists = append(notExists, orgID) continue From 349cec2ed8761c1c791029d84c69fc0e9e8991ce Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 13:08:21 -0500 Subject: [PATCH 05/12] update test with more cases --- coderd/database/dbfake/builder.go | 18 ++++++++ coderd/database/dbmem/dbmem.go | 3 ++ coderd/idpsync/organizations_test.go | 64 ++++++++++++++++------------ 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/coderd/database/dbfake/builder.go b/coderd/database/dbfake/builder.go index 67600c1856894..d916d2c7c533d 100644 --- a/coderd/database/dbfake/builder.go +++ b/coderd/database/dbfake/builder.go @@ -17,6 +17,7 @@ type OrganizationBuilder struct { t *testing.T db database.Store seed database.Organization + delete bool allUsersAllowance int32 members []uuid.UUID groups map[database.Group][]uuid.UUID @@ -45,6 +46,12 @@ func (b OrganizationBuilder) EveryoneAllowance(allowance int) OrganizationBuilde return b } +func (b OrganizationBuilder) Deleted(deleted bool) OrganizationBuilder { + //nolint: revive // returns modified struct + b.delete = deleted + return b +} + func (b OrganizationBuilder) Seed(seed database.Organization) OrganizationBuilder { //nolint: revive // returns modified struct b.seed = seed @@ -119,6 +126,17 @@ func (b OrganizationBuilder) Do() OrganizationResponse { } } + if b.delete { + now := dbtime.Now() + err = b.db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + UpdatedAt: now, + ID: org.ID, + }) + require.NoError(b.t, err) + org.Deleted = true + org.UpdatedAt = now + } + return OrganizationResponse{ Org: org, AllUsersGroup: everyone, diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 85c9248d28787..4cef34e5552b1 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -4156,6 +4156,9 @@ func (q *FakeQuerier) GetOrganizations(_ context.Context, args database.GetOrgan if args.Name != "" && !strings.EqualFold(org.Name, args.Name) { continue } + if args.Deleted != org.Deleted { + continue + } tmp = append(tmp, org) } diff --git a/coderd/idpsync/organizations_test.go b/coderd/idpsync/organizations_test.go index 360b3df0eb72a..4620452fd87a7 100644 --- a/coderd/idpsync/organizations_test.go +++ b/coderd/idpsync/organizations_test.go @@ -1,6 +1,7 @@ package idpsync_test import ( + "database/sql" "testing" "github.com/golang-jwt/jwt/v4" @@ -9,9 +10,10 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/idpsync" "github.com/coder/coder/v2/coderd/runtimeconfig" "github.com/coder/coder/v2/testutil" @@ -46,29 +48,28 @@ func TestParseOrganizationClaims(t *testing.T) { func TestSyncOrganizations(t *testing.T) { t.Parallel() + // This test creates some deleted organizations and checks the behavior is + // correct. t.Run("SyncUserToDeletedOrg", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) db, _ := dbtestutil.NewDB(t) user := dbgen.User(t, db, database.User{}) - extra := dbgen.Organization(t, db, database.Organization{}) - dbgen.OrganizationMember(t, db, database.OrganizationMember{ - UserID: user.ID, - OrganizationID: extra.ID, - }) - // Create a new organization, add in the user as a member, then delete - // the org. - org := dbgen.Organization(t, db, database.Organization{}) - dbgen.OrganizationMember(t, db, database.OrganizationMember{ - UserID: user.ID, - OrganizationID: org.ID, - }) - - err := db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ - UpdatedAt: dbtime.Now(), - ID: org.ID, - }) - require.NoError(t, err) + // Create orgs for: + // - stays = User is a member, and stays + // - leaves = User is a member, and leaves + // - joins = User is not a member, and joins + // For deleted orgs, the user **should not** be a member of afterwards. + // - deletedStays = User is a member of deleted org, and wants to stay + // - deletedLeaves = User is a member of deleted org, and wants to leave + // - deletedJoins = User is not a member of deleted org, and wants to join + stays := dbfake.Organization(t, db).Members(user).Do() + leaves := dbfake.Organization(t, db).Members(user).Do() + joins := dbfake.Organization(t, db).Do() + + deletedStays := dbfake.Organization(t, db).Members(user).Deleted(true).Do() + deletedLeaves := dbfake.Organization(t, db).Members(user).Deleted(true).Do() + deletedJoins := dbfake.Organization(t, db).Deleted(true).Do() // Now sync the user to the deleted organization s := idpsync.NewAGPLSync( @@ -77,27 +78,34 @@ func TestSyncOrganizations(t *testing.T) { idpsync.DeploymentSyncSettings{ OrganizationField: "orgs", OrganizationMapping: map[string][]uuid.UUID{ - "random": {org.ID}, - "noise": {uuid.New()}, + "stay": {stays.Org.ID, deletedStays.Org.ID}, + "leave": {leaves.Org.ID, deletedLeaves.Org.ID}, + "join": {joins.Org.ID, deletedJoins.Org.ID}, }, OrganizationAssignDefault: false, }, ) - err = s.SyncOrganizations(ctx, db, user, idpsync.OrganizationParams{ + err := s.SyncOrganizations(ctx, db, user, idpsync.OrganizationParams{ SyncEntitled: true, MergedClaims: map[string]interface{}{ - "orgs": []string{"random", "noise"}, + "orgs": []string{"stay", "join"}, }, }) require.NoError(t, err) - mems, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{ - OrganizationID: org.ID, - UserID: user.ID, - IncludeSystem: false, + orgs, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ + UserID: user.ID, + Deleted: sql.NullBool{}, }) require.NoError(t, err) - require.Len(t, mems, 1) + require.Len(t, orgs, 2) + + // Verify the user only exists in 2 orgs. The one they stayed, and the one they + // joined. + inIDs := db2sdk.List(orgs, func(org database.Organization) uuid.UUID { + return org.ID + }) + require.ElementsMatch(t, []uuid.UUID{stays.Org.ID, joins.Org.ID}, inIDs) }) } From 47e76f13f97a7eeba84a00f4690c000cb0033332 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 13:17:41 -0500 Subject: [PATCH 06/12] add test to go to zero orgs --- coderd/database/dbmem/dbmem.go | 9 ++++--- coderd/idpsync/organizations_test.go | 40 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 4cef34e5552b1..1359d2e63484d 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -2357,10 +2357,13 @@ func (q *FakeQuerier) DeleteOrganizationMember(ctx context.Context, arg database q.mutex.Lock() defer q.mutex.Unlock() - deleted := slices.DeleteFunc(q.data.organizationMembers, func(member database.OrganizationMember) bool { - return member.OrganizationID == arg.OrganizationID && member.UserID == arg.UserID + deleted := false + q.data.organizationMembers = slices.DeleteFunc(q.data.organizationMembers, func(member database.OrganizationMember) bool { + match := member.OrganizationID == arg.OrganizationID && member.UserID == arg.UserID + deleted = deleted || match + return match }) - if len(deleted) == 0 { + if !deleted { return sql.ErrNoRows } diff --git a/coderd/idpsync/organizations_test.go b/coderd/idpsync/organizations_test.go index 4620452fd87a7..d3a16eccc788a 100644 --- a/coderd/idpsync/organizations_test.go +++ b/coderd/idpsync/organizations_test.go @@ -51,6 +51,8 @@ func TestSyncOrganizations(t *testing.T) { // This test creates some deleted organizations and checks the behavior is // correct. t.Run("SyncUserToDeletedOrg", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) db, _ := dbtestutil.NewDB(t) user := dbgen.User(t, db, database.User{}) @@ -108,4 +110,42 @@ func TestSyncOrganizations(t *testing.T) { }) require.ElementsMatch(t, []uuid.UUID{stays.Org.ID, joins.Org.ID}, inIDs) }) + + t.Run("UserToZeroOrgs", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + + deletedLeaves := dbfake.Organization(t, db).Members(user).Deleted(true).Do() + + // Now sync the user to the deleted organization + s := idpsync.NewAGPLSync( + slogtest.Make(t, &slogtest.Options{}), + runtimeconfig.NewManager(), + idpsync.DeploymentSyncSettings{ + OrganizationField: "orgs", + OrganizationMapping: map[string][]uuid.UUID{ + "leave": {deletedLeaves.Org.ID}, + }, + OrganizationAssignDefault: false, + }, + ) + + err := s.SyncOrganizations(ctx, db, user, idpsync.OrganizationParams{ + SyncEntitled: true, + MergedClaims: map[string]interface{}{ + "orgs": []string{}, + }, + }) + require.NoError(t, err) + + orgs, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ + UserID: user.ID, + Deleted: sql.NullBool{}, + }) + require.NoError(t, err) + require.Len(t, orgs, 0) + }) } From 764b9449b5239198ede13fc19f215df3e7a882e2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 13:23:10 -0500 Subject: [PATCH 07/12] test: add deleted organization noise to existing enterprise idp sync test --- .../coderd/enidpsync/organizations_test.go | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/enterprise/coderd/enidpsync/organizations_test.go b/enterprise/coderd/enidpsync/organizations_test.go index 391535c9478d7..b2e120592b582 100644 --- a/enterprise/coderd/enidpsync/organizations_test.go +++ b/enterprise/coderd/enidpsync/organizations_test.go @@ -14,6 +14,7 @@ import ( "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/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/entitlements" @@ -89,7 +90,8 @@ func TestOrganizationSync(t *testing.T) { Name: "SingleOrgDeployment", Case: func(t *testing.T, db database.Store) OrganizationSyncTestCase { def, _ := db.GetDefaultOrganization(context.Background()) - other := dbgen.Organization(t, db, database.Organization{}) + other := dbfake.Organization(t, db).Do() + deleted := dbfake.Organization(t, db).Deleted(true).Do() return OrganizationSyncTestCase{ Entitlements: entitled, Settings: idpsync.DeploymentSyncSettings{ @@ -123,11 +125,19 @@ func TestOrganizationSync(t *testing.T) { }) dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: user.ID, - OrganizationID: other.ID, + OrganizationID: other.Org.ID, + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: deleted.Org.ID, }) }, Sync: ExpectedUser{ - Organizations: []uuid.UUID{def.ID, other.ID}, + Organizations: []uuid.UUID{ + def.ID, other.Org.ID, + // The user remains in the deleted org because no idp sync happens. + deleted.Org.ID, + }, }, }, }, @@ -138,17 +148,19 @@ func TestOrganizationSync(t *testing.T) { Name: "MultiOrgWithDefault", Case: func(t *testing.T, db database.Store) OrganizationSyncTestCase { def, _ := db.GetDefaultOrganization(context.Background()) - one := dbgen.Organization(t, db, database.Organization{}) - two := dbgen.Organization(t, db, database.Organization{}) - three := dbgen.Organization(t, db, database.Organization{}) + one := dbfake.Organization(t, db).Do() + two := dbfake.Organization(t, db).Do() + three := dbfake.Organization(t, db).Do() + deleted := dbfake.Organization(t, db).Deleted(true).Do() return OrganizationSyncTestCase{ Entitlements: entitled, Settings: idpsync.DeploymentSyncSettings{ OrganizationField: "organizations", OrganizationMapping: map[string][]uuid.UUID{ - "first": {one.ID}, - "second": {two.ID}, - "third": {three.ID}, + "first": {one.Org.ID}, + "second": {two.Org.ID}, + "third": {three.Org.ID}, + "deleted": {deleted.Org.ID}, }, OrganizationAssignDefault: true, }, @@ -167,7 +179,7 @@ func TestOrganizationSync(t *testing.T) { { Name: "AlreadyInOrgs", Claims: jwt.MapClaims{ - "organizations": []string{"second", "extra"}, + "organizations": []string{"second", "extra", "deleted"}, }, ExpectedParams: idpsync.OrganizationParams{ SyncEntitled: true, @@ -180,18 +192,18 @@ func TestOrganizationSync(t *testing.T) { }) dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: user.ID, - OrganizationID: one.ID, + OrganizationID: one.Org.ID, }) }, Sync: ExpectedUser{ - Organizations: []uuid.UUID{def.ID, two.ID}, + Organizations: []uuid.UUID{def.ID, two.Org.ID}, }, }, { Name: "ManyClaims", Claims: jwt.MapClaims{ // Add some repeats - "organizations": []string{"second", "extra", "first", "third", "second", "second"}, + "organizations": []string{"second", "extra", "first", "third", "second", "second", "deleted"}, }, ExpectedParams: idpsync.OrganizationParams{ SyncEntitled: true, @@ -204,11 +216,11 @@ func TestOrganizationSync(t *testing.T) { }) dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: user.ID, - OrganizationID: one.ID, + OrganizationID: one.Org.ID, }) }, Sync: ExpectedUser{ - Organizations: []uuid.UUID{def.ID, one.ID, two.ID, three.ID}, + Organizations: []uuid.UUID{def.ID, one.Org.ID, two.Org.ID, three.Org.ID}, }, }, }, From 92744cfa689d2f739f87821591a464bd71f51b60 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 13:24:04 -0500 Subject: [PATCH 08/12] linting --- coderd/database/dbauthz/dbauthz_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 711934a2c1146..3ac7784961759 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -886,7 +886,7 @@ func (s *MethodTestSuite) TestOrganization() { _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID, OrganizationID: a.ID}) b := dbgen.Organization(s.T(), db, database.Organization{}) _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID, OrganizationID: b.ID}) - check.Args(database.GetOrganizationsByUserIDParams{UserID: u.ID, Deleted: false}).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) + check.Args(database.GetOrganizationsByUserIDParams{UserID: u.ID, Deleted: sql.NullBool{Valid: true, Bool: false}}).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) })) s.Run("InsertOrganization", s.Subtest(func(db database.Store, check *expects) { check.Args(database.InsertOrganizationParams{ From 6ddd69e28fc6637bdeb33b5590f1576ea41ceb63 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 13:30:25 -0500 Subject: [PATCH 09/12] more comments --- coderd/idpsync/organization.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/coderd/idpsync/organization.go b/coderd/idpsync/organization.go index 84b38e36c60ff..5af4010a621c2 100644 --- a/coderd/idpsync/organization.go +++ b/coderd/idpsync/organization.go @@ -115,9 +115,13 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u // 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 + // 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 { @@ -158,6 +162,10 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u // 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), From 909c895855859c59aac8696014f34453ea2d253a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 13:33:03 -0500 Subject: [PATCH 10/12] fix dbmem test case --- coderd/database/dbauthz/dbauthz_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 3ac7784961759..e562bbd1f7160 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -994,8 +994,7 @@ func (s *MethodTestSuite) TestOrganization() { member, policy.ActionRead, member, policy.ActionDelete). WithNotAuthorized("no rows"). - WithCancelled(cancelledErr). - ErrorsWithInMemDB(sql.ErrNoRows) + WithCancelled(cancelledErr) })) s.Run("UpdateOrganization", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{ From 0dab7bb55b1f93f5314c0ac7a6e0ac849e3ce37f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 15 Apr 2025 19:30:53 -0500 Subject: [PATCH 11/12] Update coderd/idpsync/organization.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ケイラ --- coderd/idpsync/organization.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/idpsync/organization.go b/coderd/idpsync/organization.go index 5af4010a621c2..be65daba369df 100644 --- a/coderd/idpsync/organization.go +++ b/coderd/idpsync/organization.go @@ -189,7 +189,7 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u } if len(notExists) > 0 { - notExists = slice.Unique(notExists) // Remove dupes + 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), From 3b49f6ce19c0a8e11b58c6cd21a4086f4bb6e680 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 16 Apr 2025 08:44:38 -0500 Subject: [PATCH 12/12] spacing for formatting --- coderd/idpsync/organizations_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/idpsync/organizations_test.go b/coderd/idpsync/organizations_test.go index d3a16eccc788a..3a00499bdbced 100644 --- a/coderd/idpsync/organizations_test.go +++ b/coderd/idpsync/organizations_test.go @@ -58,13 +58,13 @@ func TestSyncOrganizations(t *testing.T) { user := dbgen.User(t, db, database.User{}) // Create orgs for: - // - stays = User is a member, and stays + // - stays = User is a member, and stays // - leaves = User is a member, and leaves // - joins = User is not a member, and joins // For deleted orgs, the user **should not** be a member of afterwards. - // - deletedStays = User is a member of deleted org, and wants to stay + // - deletedStays = User is a member of deleted org, and wants to stay // - deletedLeaves = User is a member of deleted org, and wants to leave - // - deletedJoins = User is not a member of deleted org, and wants to join + // - deletedJoins = User is not a member of deleted org, and wants to join stays := dbfake.Organization(t, db).Members(user).Do() leaves := dbfake.Organization(t, db).Members(user).Do() joins := dbfake.Organization(t, db).Do() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy