package idpsync_test import ( "context" "encoding/json" "slices" "testing" "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/idpsync" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/runtimeconfig" "github.com/coder/coder/v2/testutil" ) func TestRoleSyncTable(t *testing.T) { t.Parallel() if dbtestutil.WillUsePostgres() { t.Skip("Skipping test because it populates a lot of db entries, which is slow on postgres.") } userClaims := jwt.MapClaims{ "roles": []string{ "foo", "bar", "baz", "create-bar", "create-baz", "legacy-bar", rbac.RoleOrgAuditor(), }, // bad-claim is a number, and will fail any role sync "bad-claim": 100, "empty": []string{}, } testCases := []orgSetupDefinition{ { Name: "NoSync", OrganizationRoles: []string{}, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{}, }, }, { Name: "SyncDisabled", OrganizationRoles: []string{ rbac.RoleOrgAdmin(), }, RoleSettings: &idpsync.RoleSyncSettings{}, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{ rbac.RoleOrgAdmin(), }, }, }, { // Audit role from claim Name: "RawAudit", OrganizationRoles: []string{ rbac.RoleOrgAdmin(), }, RoleSettings: &idpsync.RoleSyncSettings{ Field: "roles", Mapping: map[string][]string{}, }, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{ rbac.RoleOrgAuditor(), }, }, }, { Name: "CustomRole", OrganizationRoles: []string{ rbac.RoleOrgAdmin(), }, CustomRoles: []string{"foo"}, RoleSettings: &idpsync.RoleSyncSettings{ Field: "roles", Mapping: map[string][]string{}, }, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{ rbac.RoleOrgAuditor(), "foo", }, }, }, { Name: "RoleMapping", OrganizationRoles: []string{ rbac.RoleOrgAdmin(), "invalid", // Throw in an extra invalid role that will be removed }, CustomRoles: []string{"custom"}, RoleSettings: &idpsync.RoleSyncSettings{ Field: "roles", Mapping: map[string][]string{ "foo": {"custom", rbac.RoleOrgTemplateAdmin()}, }, }, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{ rbac.RoleOrgAuditor(), rbac.RoleOrgTemplateAdmin(), "custom", }, }, }, { // InvalidClaims will log an error, but do not block authentication. // This is to prevent a misconfigured organization from blocking // a user from authenticating. Name: "InvalidClaim", OrganizationRoles: []string{rbac.RoleOrgAdmin()}, RoleSettings: &idpsync.RoleSyncSettings{ Field: "bad-claim", }, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{ rbac.RoleOrgAdmin(), }, }, }, { Name: "NoChange", OrganizationRoles: []string{rbac.RoleOrgAdmin(), rbac.RoleOrgTemplateAdmin(), rbac.RoleOrgAuditor()}, RoleSettings: &idpsync.RoleSyncSettings{ Field: "roles", Mapping: map[string][]string{ "foo": {rbac.RoleOrgAuditor(), rbac.RoleOrgTemplateAdmin()}, "bar": {rbac.RoleOrgAdmin()}, }, }, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{ rbac.RoleOrgAdmin(), rbac.RoleOrgAuditor(), rbac.RoleOrgTemplateAdmin(), }, }, }, { // InvalidOriginalRole starts the user with an invalid role. // In practice, this should not happen, as it means a role was // inserted into the database that does not exist. // For the purposes of syncing, it does not matter, and the sync // should succeed. Name: "InvalidOriginalRole", OrganizationRoles: []string{"something-bad"}, RoleSettings: &idpsync.RoleSyncSettings{ Field: "roles", Mapping: map[string][]string{}, }, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{ rbac.RoleOrgAuditor(), }, }, }, { Name: "NonExistentClaim", OrganizationRoles: []string{rbac.RoleOrgAuditor()}, RoleSettings: &idpsync.RoleSyncSettings{ Field: "not-exists", Mapping: map[string][]string{}, }, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{}, }, }, { Name: "EmptyClaim", OrganizationRoles: []string{rbac.RoleOrgAuditor()}, RoleSettings: &idpsync.RoleSyncSettings{ Field: "empty", Mapping: map[string][]string{}, }, assertRoles: &orgRoleAssert{ ExpectedOrgRoles: []string{}, }, }, } for _, tc := range testCases { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) manager := runtimeconfig.NewManager() s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{ IgnoreErrors: true, }), manager, idpsync.DeploymentSyncSettings{ SiteRoleField: "roles", }, ) ctx := testutil.Context(t, testutil.WaitSuperLong) user := dbgen.User(t, db, database.User{}) orgID := uuid.New() SetupOrganization(t, s, db, user, orgID, tc) // Do the role sync! err := s.SyncRoles(ctx, db, user, idpsync.RoleParams{ SyncEntitled: true, SyncSiteWide: false, MergedClaims: userClaims, }) require.NoError(t, err) tc.Assert(t, orgID, db, user) }) } // AllTogether runs the entire tabled test as a singular user and // deployment. This tests all organizations being synced together. // The reason we do them individually, is that it is much easier to // debug a single test case. t.Run("AllTogether", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) manager := runtimeconfig.NewManager() s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{ IgnoreErrors: true, }), manager, // Also sync some site wide roles idpsync.DeploymentSyncSettings{ GroupField: "groups", SiteRoleField: "roles", // Site sync settings do not matter, // as we are not testing the site parse here. // Only the sync, assuming the parse is correct. }, ) ctx := testutil.Context(t, testutil.WaitSuperLong) user := dbgen.User(t, db, database.User{}) var asserts []func(t *testing.T) for _, tc := range testCases { tc := tc orgID := uuid.New() SetupOrganization(t, s, db, user, orgID, tc) asserts = append(asserts, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { t.Parallel() tc.Assert(t, orgID, db, user) }) }) } err := s.SyncRoles(ctx, db, user, idpsync.RoleParams{ SyncEntitled: true, SyncSiteWide: true, SiteWideRoles: []string{ rbac.RoleTemplateAdmin().Name, // Duplicate this value to test deduplication rbac.RoleTemplateAdmin().Name, rbac.RoleAuditor().Name, }, MergedClaims: userClaims, }) require.NoError(t, err) for _, assert := range asserts { assert(t) } // Also assert site wide roles //nolint:gocritic // unit testing assertions allRoles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID) require.NoError(t, err) allRoleIDs, err := allRoles.RoleNames() require.NoError(t, err) // Remove the org roles siteRoles := slices.DeleteFunc(allRoleIDs, func(r rbac.RoleIdentifier) bool { return r.IsOrgRole() }) require.ElementsMatch(t, []rbac.RoleIdentifier{ rbac.RoleTemplateAdmin(), rbac.RoleAuditor(), rbac.RoleMember(), }, siteRoles) }) } // TestNoopNoDiff verifies if no role change occurs, no database call is taken // per organization. This limits the number of db calls to O(1) if there // are no changes. Which is the usual case, as user's roles do not change often. func TestNoopNoDiff(t *testing.T) { t.Parallel() ctx := context.Background() ctrl := gomock.NewController(t) mDB := dbmock.NewMockStore(ctrl) mgr := runtimeconfig.NewManager() s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}), mgr, idpsync.DeploymentSyncSettings{ SiteRoleField: "", SiteRoleMapping: nil, SiteDefaultRoles: nil, }) userID := uuid.New() orgID := uuid.New() siteRoles := []string{rbac.RoleTemplateAdmin().Name, rbac.RoleAuditor().Name} orgRoles := []string{rbac.RoleOrgAuditor(), rbac.RoleOrgAdmin()} // The DB mock expects. // If this test fails, feel free to add more expectations. // The primary expectations to avoid is 'UpdateUserRoles' // and 'UpdateMemberRoles'. mDB.EXPECT().InTx( gomock.Any(), gomock.Any(), ).DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error { err := f(mDB) return err }) mDB.EXPECT().OrganizationMembers(gomock.Any(), database.OrganizationMembersParams{ UserID: userID, }).Return([]database.OrganizationMembersRow{ { OrganizationMember: database.OrganizationMember{ UserID: userID, OrganizationID: orgID, Roles: orgRoles, }, }, }, nil) mDB.EXPECT().GetRuntimeConfig(gomock.Any(), gomock.Any()).Return( string(must(json.Marshal(idpsync.RoleSyncSettings{ Field: "roles", Mapping: nil, }))), nil) err := s.SyncRoles(ctx, mDB, database.User{ ID: userID, Email: "alice@email.com", Username: "alice", Status: database.UserStatusActive, RBACRoles: siteRoles, LoginType: database.LoginTypePassword, }, idpsync.RoleParams{ SyncEntitled: true, SyncSiteWide: true, SiteWideRoles: siteRoles, MergedClaims: jwt.MapClaims{ "roles": orgRoles, }, }) require.NoError(t, err) } func must[T any](value T, err error) T { if err != nil { panic(err) } return value }