// Copyright IBM Corp. 2020, 2025 // SPDX-License-Identifier: BUSL-1.1 package iam_test import ( "context" "fmt" "sort" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/auth/oidc" "github.com/hashicorp/boundary/internal/auth/password" "github.com/hashicorp/boundary/internal/auth/store" "github.com/hashicorp/boundary/internal/db" dbassert "github.com/hashicorp/boundary/internal/db/assert" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/oplog" "github.com/hashicorp/go-secure-stdlib/strutil" "github.com/hashicorp/go-uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" ) func TestRepository_CreateUser(t *testing.T) { t.Parallel() ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) wrapper := db.TestWrapper(t) repo := iam.TestRepo(t, conn, wrapper) id := testId(t) org, _ := iam.TestScopes(t, repo) type args struct { user *iam.User opt []iam.Option } tests := []struct { name string args args wantDup bool wantErr bool wantErrMsg string }{ { name: "valid", args: args{ user: func() *iam.User { u, err := iam.NewUser(ctx, org.PublicId, iam.WithName("valid"+id), iam.WithDescription(id)) assert.NoError(t, err) return u }(), }, wantErr: false, }, { name: "bad-scope-id", args: args{ user: func() *iam.User { u, err := iam.NewUser(ctx, id) assert.NoError(t, err) return u }(), }, wantErr: true, wantErrMsg: "iam.(Repository).create: error getting metadata: iam.(Repository).stdMetadata: unable to get scope: iam.LookupScope: db.LookupWhere: record not found", }, { name: "dup-name", args: args{ user: func() *iam.User { u, err := iam.NewUser(ctx, org.PublicId, iam.WithName("dup-name"+id)) assert.NoError(t, err) return u }(), }, wantDup: true, wantErr: true, wantErrMsg: "iam.(Repository).CreateUser: user %s already exists in org %s: integrity violation: error #1002", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) if tt.wantDup { dup, err := repo.CreateUser(ctx, tt.args.user, tt.args.opt...) require.NoError(err) require.NotNil(dup) } u, err := repo.CreateUser(ctx, tt.args.user, tt.args.opt...) if tt.wantErr { require.Error(err) assert.Nil(u) switch tt.name { case "dup-name": assert.Contains(err.Error(), fmt.Sprintf(tt.wantErrMsg, "dup-name"+id, org.PublicId)) default: assert.Contains(err.Error(), tt.wantErrMsg) } return } require.NoError(err) assert.NotNil(u.CreateTime) assert.NotNil(u.UpdateTime) foundUser, _, err := repo.LookupUser(ctx, u.PublicId) require.NoError(err) assert.True(proto.Equal(foundUser, u)) err = db.TestVerifyOplog(t, rw, u.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_CREATE), db.WithCreateNotBefore(10*time.Second)) assert.NoError(err) }) } } // TestRepository_LookupUser_WithDifferentPrimaryAuthMethods ensures that the // when different auth method types are primary, the correct primary account // info is returned from Repository.LookupUser(...) func TestRepository_LookupUser_WithDifferentPrimaryAuthMethods(t *testing.T) { t.Parallel() ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") wrapper := db.TestWrapper(t) kmsCache := kms.TestKms(t, conn, wrapper) repo := iam.TestRepo(t, conn, wrapper) org, _ := iam.TestScopes(t, repo) databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) require.NoError(t, err) var accountIds []string oidcAm := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, "alice-rp", "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) aa := oidc.TestAccount(t, conn, oidcAm, "alice", oidc.WithFullName("alice eve smith"), oidc.WithEmail("alice@example.com")) accountIds = append(accountIds, aa.PublicId) pwAms := password.TestAuthMethods(t, conn, org.PublicId, 1) require.Equal(t, 1, len(pwAms)) pwAcct := password.TestAccount(t, conn, pwAms[0].PublicId, "want-login-name") accountIds = append(accountIds, pwAcct.PublicId) u := iam.TestUser(t, repo, org.PublicId) newAccts, err := repo.AddUserAccounts(ctx, u.PublicId, u.Version, accountIds) require.NoError(t, err) sort.Strings(newAccts) require.Equal(t, accountIds, newAccts) tests := []struct { name string primaryAuthMethodId string wantLoginName string wantPrimaryAcctId string wantFullName string wantEmail string }{ { name: "oidc", primaryAuthMethodId: oidcAm.PublicId, wantLoginName: "alice", wantPrimaryAcctId: aa.PublicId, wantFullName: "alice eve smith", wantEmail: "alice@example.com", }, { name: "password", primaryAuthMethodId: pwAms[0].PublicId, wantLoginName: "want-login-name", wantPrimaryAcctId: pwAcct.PublicId, wantFullName: "", wantEmail: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) s, err := repo.LookupScope(context.Background(), org.PublicId) require.NoError(err) iam.TestSetPrimaryAuthMethod(t, repo, s, tt.primaryAuthMethodId) got, gotAccts, err := repo.LookupUser(ctx, u.PublicId) require.NoError(err) sort.Strings(gotAccts) assert.Equal(accountIds, gotAccts) assert.Equal(tt.wantLoginName, got.LoginName) assert.Equal(tt.wantPrimaryAcctId, got.PrimaryAccountId) assert.Equal(tt.wantFullName, got.FullName) assert.Equal(tt.wantEmail, got.Email) }) } } func TestRepository_UpdateUser(t *testing.T) { t.Parallel() ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) wrapper := db.TestWrapper(t) kmsCache := kms.TestKms(t, conn, wrapper) repo := iam.TestRepo(t, conn, wrapper) id := testId(t) org, proj := iam.TestScopes(t, repo) databaseWrapper, err := kmsCache.GetWrapper(context.Background(), org.PublicId, kms.KeyPurposeDatabase) require.NoError(t, err) pubId := func(s string) *string { return &s } type args struct { name string description string fieldMaskPaths []string opt []iam.Option ScopeId string PublicId *string } tests := []struct { name string newUserOpts []iam.Option args args wantRowsUpdate int wantErr bool wantErrMsg string wantIsErr errors.Code wantDup bool }{ { name: "valid", args: args{ name: "valid" + id, fieldMaskPaths: []string{"Name"}, ScopeId: org.PublicId, }, wantErr: false, wantRowsUpdate: 1, }, { name: "valid-no-op", args: args{ name: "valid-no-op" + id, fieldMaskPaths: []string{"Name"}, ScopeId: org.PublicId, }, newUserOpts: []iam.Option{iam.WithName("valid-no-op" + id)}, wantErr: false, wantRowsUpdate: 1, }, { name: "not-found", args: args{ name: "not-found" + id, fieldMaskPaths: []string{"Name"}, ScopeId: org.PublicId, PublicId: func() *string { s := "1"; return &s }(), }, wantErr: true, wantRowsUpdate: 0, wantErrMsg: "iam.(Repository).UpdateUser: db.Update: record not found, search issue: error #1100", wantIsErr: errors.RecordNotFound, }, { name: "null-name", args: args{ name: "", fieldMaskPaths: []string{"Name"}, ScopeId: org.PublicId, }, newUserOpts: []iam.Option{iam.WithName("null-name" + id)}, wantErr: false, wantRowsUpdate: 1, }, { name: "null-description", args: args{ name: "", fieldMaskPaths: []string{"Description"}, ScopeId: org.PublicId, }, newUserOpts: []iam.Option{iam.WithDescription("null-description" + id)}, wantErr: false, wantRowsUpdate: 1, }, { name: "empty-field-mask", args: args{ name: "valid" + id, fieldMaskPaths: []string{}, ScopeId: org.PublicId, }, wantErr: true, wantRowsUpdate: 0, wantErrMsg: "iam.(Repository).UpdateUser: empty field mask, parameter violation: error #104", }, { name: "nil-fieldmask", args: args{ name: "valid" + id, fieldMaskPaths: nil, ScopeId: org.PublicId, }, wantErr: true, wantRowsUpdate: 0, wantErrMsg: "iam.(Repository).UpdateUser: empty field mask, parameter violation: error #104", }, { name: "read-only-fields", args: args{ name: "valid" + id, fieldMaskPaths: []string{"CreateTime"}, ScopeId: org.PublicId, }, wantErr: true, wantRowsUpdate: 0, wantErrMsg: "iam.(Repository).UpdateUser: invalid field mask: CreateTime: parameter violation: error #103", }, { name: "unknown-fields", args: args{ name: "valid" + id, fieldMaskPaths: []string{"Alice"}, ScopeId: org.PublicId, }, wantErr: true, wantRowsUpdate: 0, wantErrMsg: "iam.(Repository).UpdateUser: invalid field mask: Alice: parameter violation: error #103", }, { name: "no-public-id", args: args{ name: "valid" + id, fieldMaskPaths: []string{"Name"}, ScopeId: org.PublicId, PublicId: pubId(""), }, wantErr: true, wantErrMsg: "iam.(Repository).UpdateUser: missing public id: parameter violation: error #100", wantRowsUpdate: 0, }, { name: "proj-scope-id", args: args{ name: "proj-scope-id" + id, fieldMaskPaths: []string{"ScopeId"}, ScopeId: proj.PublicId, }, wantErr: true, wantErrMsg: "iam.(Repository).UpdateUser: invalid field mask: ScopeId: parameter violation: error #103", }, { name: "empty-scope-id-with-name-mask", args: args{ name: "empty-scope-id" + id, fieldMaskPaths: []string{"Name"}, ScopeId: "", }, wantErr: false, wantRowsUpdate: 1, }, { name: "dup-name", args: args{ name: "dup-name" + id, fieldMaskPaths: []string{"Name"}, ScopeId: org.PublicId, }, wantErr: true, wantDup: true, wantErrMsg: `iam.(Repository).UpdateUser: user %s already exists in org %s: integrity violation: error #1002`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) // we need to clean out any auth methods _, err = rw.Exec(context.Background(), "delete from auth_method", nil) require.NoError(err) if tt.wantDup { u := iam.TestUser(t, repo, org.PublicId, tt.newUserOpts...) u.Name = tt.args.name _, _, _, err := repo.UpdateUser(context.Background(), u, 1, tt.args.fieldMaskPaths, tt.args.opt...) require.NoError(err) } u := iam.TestUser(t, repo, org.PublicId, tt.newUserOpts...) acctCount := 10 accountIds := make([]string, 0, acctCount) var wantEmail, wantFullName, wantPrimaryAccountId string var authMethod *oidc.AuthMethod for i := 0; i < acctCount; i++ { switch i % 2 { case 0: authMethod = oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, fmt.Sprintf("alice-rp-%d", i), "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) wantEmail, wantFullName = fmt.Sprintf("%s-%d@example.com", tt.name, i), fmt.Sprintf("%s-%d", tt.name, i) aa := oidc.TestAccount(t, conn, authMethod, fmt.Sprintf(tt.name, i), oidc.WithFullName(wantFullName), oidc.WithEmail(wantEmail)) wantPrimaryAccountId = aa.PublicId accountIds = append(accountIds, aa.PublicId) default: pwAms := password.TestAuthMethods(t, conn, org.PublicId, 1) require.Equal(1, len(pwAms)) pwAcct := password.TestAccount(t, conn, pwAms[0].PublicId, "name1") accountIds = append(accountIds, pwAcct.PublicId) } } require.Equal(acctCount, len(accountIds)) var s *iam.Scope s, err = repo.LookupScope(context.Background(), org.PublicId) require.NoError(err) // we need a primary auth method, so let's just pick the last oidc // one created. iam.TestSetPrimaryAuthMethod(t, repo, s, authMethod.PublicId) sort.Strings(accountIds) if len(accountIds) > 0 { newAccts, err := repo.AddUserAccounts(context.Background(), u.PublicId, u.Version, accountIds) require.NoError(err) sort.Strings(newAccts) require.Equal(accountIds, newAccts) u.Version++ } // we need to clean out any oplog entries added because we // associated accounts to the test user _, err = rw.Exec(context.Background(), "delete from oplog_entry", nil) require.NoError(err) updateUser := iam.AllocUser() updateUser.PublicId = u.PublicId if tt.args.PublicId != nil { updateUser.PublicId = *tt.args.PublicId } updateUser.ScopeId = tt.args.ScopeId updateUser.Name = tt.args.name updateUser.Description = tt.args.description var userAfterUpdate *iam.User var acctIdsAfterUpdate []string var updatedRows int var err error userAfterUpdate, acctIdsAfterUpdate, updatedRows, err = repo.UpdateUser(context.Background(), &updateUser, u.Version, tt.args.fieldMaskPaths, tt.args.opt...) if tt.wantErr { require.Error(err) assert.True(errors.Match(errors.T(tt.wantIsErr), err)) assert.Nil(userAfterUpdate) assert.Equal(0, updatedRows) switch tt.name { case "dup-name": assert.Equal(fmt.Sprintf(tt.wantErrMsg, "dup-name"+id, org.PublicId), err.Error()) default: assert.Containsf(err.Error(), tt.wantErrMsg, "unexpected error: %s", err.Error()) } err = db.TestVerifyOplog(t, rw, u.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(1*time.Second)) require.Error(err) assert.Contains(err.Error(), "record not found") return } require.NoError(err) assert.Equal(tt.wantRowsUpdate, updatedRows) assert.NotEqual(u.UpdateTime, userAfterUpdate.UpdateTime) sort.Strings(acctIdsAfterUpdate) assert.Equal(accountIds, acctIdsAfterUpdate) assert.Equal(wantFullName, userAfterUpdate.FullName) assert.Equal(wantEmail, userAfterUpdate.Email) assert.Equal(wantPrimaryAccountId, userAfterUpdate.PrimaryAccountId) foundUser, foundAccountIds, err := repo.LookupUser(context.Background(), u.PublicId) require.NoError(err) assert.True(proto.Equal(userAfterUpdate, foundUser)) sort.Strings(foundAccountIds) assert.Equal(accountIds, foundAccountIds) underlyingDB, err := conn.SqlDB(ctx) require.NoError(err) dbassert := dbassert.New(t, underlyingDB) if tt.args.name == "" { dbassert.IsNull(foundUser, "name") } if tt.args.description == "" { dbassert.IsNull(foundUser, "description") } err = db.TestVerifyOplog(t, rw, u.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) assert.NoError(err) }) } } func TestRepository_DeleteUser(t *testing.T) { t.Parallel() ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) wrapper := db.TestWrapper(t) repo := iam.TestRepo(t, conn, wrapper) org, _ := iam.TestScopes(t, repo) type args struct { user *iam.User opt []iam.Option } tests := []struct { name string args args wantRowsDeleted int wantErr bool wantErrMsg string }{ { name: "valid", args: args{ user: iam.TestUser(t, repo, org.PublicId), }, wantRowsDeleted: 1, wantErr: false, }, { name: "no-public-id", args: args{ user: func() *iam.User { u := iam.AllocUser() return &u }(), }, wantRowsDeleted: 0, wantErr: true, wantErrMsg: "iam.(Repository).DeleteUser: missing public id: parameter violation: error #100", }, { name: "not-found", args: args{ user: func() *iam.User { u, err := iam.NewUser(ctx, org.PublicId) require.NoError(t, err) id, err := db.NewPublicId(ctx, globals.UserPrefix) require.NoError(t, err) u.PublicId = id return u }(), }, wantRowsDeleted: 1, wantErr: true, wantErrMsg: "db.LookupById: record not found, search issue: error #1100", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) deletedRows, err := repo.DeleteUser(ctx, tt.args.user.PublicId, tt.args.opt...) if tt.wantErr { require.Error(err) assert.Equal(0, deletedRows) assert.Contains(err.Error(), tt.wantErrMsg) err = db.TestVerifyOplog(t, rw, tt.args.user.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_DELETE), db.WithCreateNotBefore(10*time.Second)) require.Error(err) assert.Contains(err.Error(), "record not found") return } require.NoError(err) assert.Equal(tt.wantRowsDeleted, deletedRows) foundUser, _, err := repo.LookupUser(ctx, tt.args.user.PublicId) require.NoError(err) assert.Nil(foundUser) err = db.TestVerifyOplog(t, rw, tt.args.user.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_DELETE), db.WithCreateNotBefore(10*time.Second)) require.NoError(err) }) } } func TestRepository_ListUsers(t *testing.T) { t.Parallel() conn, _ := db.TestSetup(t, "postgres") const testLimit = 10 wrapper := db.TestWrapper(t) kmsCache := kms.TestKms(t, conn, wrapper) repo := iam.TestRepo(t, conn, wrapper, iam.WithLimit(testLimit)) org, _ := iam.TestScopes(t, repo) databaseWrapper, err := kmsCache.GetWrapper(context.Background(), org.PublicId, kms.KeyPurposeDatabase) require.NoError(t, err) authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, "alice-rp", "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) iam.TestSetPrimaryAuthMethod(t, repo, org, authMethod.PublicId) type args struct { withOrgId string opt []iam.Option } tests := []struct { name string createCnt int args args wantCnt int wantErr bool }{ { name: "negative-limit", createCnt: testLimit + 1, args: args{ withOrgId: org.PublicId, opt: []iam.Option{iam.WithLimit(-1)}, }, wantErr: true, }, { name: "default-limit", createCnt: testLimit + 1, args: args{ withOrgId: org.PublicId, }, wantCnt: testLimit, wantErr: false, }, { name: "custom-limit", createCnt: testLimit + 1, args: args{ withOrgId: org.PublicId, opt: []iam.Option{iam.WithLimit(3)}, }, wantCnt: 3, wantErr: false, }, { name: "bad-org", createCnt: 1, args: args{ withOrgId: "bad-id", }, wantCnt: 0, wantErr: false, }, } type userInfo struct { email string fullName string primaryAcctId string } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) db.TestDeleteWhere(t, conn, func() any { u := iam.AllocUser(); return &u }(), "public_id != 'u_anon' and public_id != 'u_auth' and public_id != 'u_recovery'") testUsers := []*iam.User{} wantUserInfo := map[string]userInfo{} for i := 0; i < tt.createCnt; i++ { u := iam.TestUser(t, repo, org.PublicId) testUsers = append(testUsers, u) wantEmail, wantFullName := fmt.Sprintf("%s-%d@example.com", tt.name, i), fmt.Sprintf("%s-%d", tt.name, i) a := oidc.TestAccount(t, conn, authMethod, fmt.Sprintf(tt.name, i), oidc.WithFullName(wantFullName), oidc.WithEmail(wantEmail)) wantUserInfo[u.PublicId] = userInfo{email: wantEmail, fullName: wantFullName, primaryAcctId: a.PublicId} _, err := repo.AddUserAccounts(context.Background(), u.PublicId, u.Version, []string{a.PublicId}) require.NoError(err) pwAms := password.TestAuthMethods(t, conn, org.PublicId, 1) require.Equal(1, len(pwAms)) pwAcct := password.TestAccount(t, conn, pwAms[0].PublicId, "name1") _, err = repo.AddUserAccounts(context.Background(), u.PublicId, u.Version+1, []string{pwAcct.PublicId}) require.NoError(err) } assert.Equal(tt.createCnt, len(testUsers)) got, _, err := repo.ListUsers(context.Background(), []string{tt.args.withOrgId}, tt.args.opt...) if tt.wantErr { require.Error(err) return } require.NoError(err) assert.Equal(tt.wantCnt, len(got)) for _, u := range got { assert.Equal(wantUserInfo[u.PublicId], userInfo{ email: u.Email, fullName: u.FullName, primaryAcctId: u.PrimaryAccountId, }) } }) } } func TestRepository_ListUsers_Multiple_Scopes(t *testing.T) { t.Parallel() conn, _ := db.TestSetup(t, "postgres") wrapper := db.TestWrapper(t) repo := iam.TestRepo(t, conn, wrapper) org, _ := iam.TestScopes(t, repo) t.Cleanup(func() { db.TestDeleteWhere(t, conn, func() any { i := iam.AllocUser(); return &i }(), "public_id != 'u_anon' and public_id != 'u_auth' and public_id != 'u_recovery'") }) const numPerScope = 10 total := 3 // anon, auth, recovery for i := 0; i < numPerScope; i++ { iam.TestUser(t, repo, "global") total++ iam.TestUser(t, repo, org.GetPublicId()) total++ } got, ttime, err := repo.ListUsers(context.Background(), []string{"global", org.GetPublicId()}) require.NoError(t, err) assert.Equal(t, total, len(got)) // Transaction timestamp should be within ~10 seconds of now assert.True(t, time.Now().Before(ttime.Add(10*time.Second))) assert.True(t, time.Now().After(ttime.Add(-10*time.Second))) } func TestRepository_LookupUserWithLogin(t *testing.T) { t.Parallel() ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) wrapper := db.TestWrapper(t) kmsCache := kms.TestKms(t, conn, wrapper) repo := iam.TestRepo(t, conn, wrapper) id := testId(t) org, _ := iam.TestScopes(t, repo) databaseWrapper, err := kmsCache.GetWrapper(context.Background(), org.PublicId, kms.KeyPurposeDatabase) require.NoError(t, err) authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, "alice-rp", "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) iam.TestSetPrimaryAuthMethod(t, repo, org, authMethod.PublicId) // an account with no assoc user to test auto-vivify for the primary auth method. newAuthAcct := oidc.TestAccount(t, conn, authMethod, "acct-1", oidc.WithFullName("acct-1"), oidc.WithEmail("acct-1@example.com")) // a 2nd auth method which will NOT be the primary auth method authMethod2 := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, "alice-rp-2", "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) // an account with no assoc user to test non-primary auth methods do not auto-vivify newAuthAcctWithoutVivify := oidc.TestAccount(t, conn, authMethod2, "acct-2", oidc.WithFullName("acct-2"), oidc.WithEmail("acct-2@example.com")) user := iam.TestUser(t, repo, org.PublicId, iam.WithName("existing-"+id)) existingUserWithAcctWithVivify := oidc.TestAccount(t, conn, authMethod, "acct-3", oidc.WithFullName("acct-3"), oidc.WithEmail("acct-3@example.com")) added, err := repo.AddUserAccounts(context.Background(), user.PublicId, user.Version, []string{existingUserWithAcctWithVivify.PublicId}) require.NoError(t, err) require.Equal(t, 1, len(added)) foundAcct := allocAuthAccount() foundAcct.PublicId = existingUserWithAcctWithVivify.PublicId require.NoError(t, rw.LookupById(context.Background(), &foundAcct)) require.Equal(t, user.PublicId, foundAcct.IamUserId) // we need to set these primary acct values on the user, so the test // comparisons will work properly. user.LoginName = "acct-3" user.FullName = "acct-3" user.Email = "acct-3@example.com" existingUserWithAcctNoVivify := oidc.TestAccount(t, conn, authMethod2, "acct-4", oidc.WithFullName("acct-4"), oidc.WithEmail("acct-4@example.com")) user, _, err = repo.LookupUser(context.Background(), user.PublicId) require.NoError(t, err) added, err = repo.AddUserAccounts(context.Background(), user.PublicId, user.Version, []string{existingUserWithAcctNoVivify.PublicId}) require.NoError(t, err) require.Equal(t, 2, len(added)) foundAcct.PublicId = existingUserWithAcctNoVivify.PublicId require.NoError(t, rw.LookupById(context.Background(), &foundAcct, db.WithWhere("iam_user_id = ?", user.PublicId))) require.Equal(t, user.PublicId, foundAcct.IamUserId) type args struct { withAccountId string opt []iam.Option } tests := []struct { name string args args wantName string wantDescription string wantErr bool wantErrIs errors.Code wantUser *iam.User }{ { name: "valid", args: args{ withAccountId: newAuthAcct.PublicId, opt: []iam.Option{ iam.WithName("valid-" + id), iam.WithDescription("valid-" + id), }, }, wantName: "valid-" + id, wantDescription: "valid-" + id, wantErr: false, }, { name: "new-acct-without-vivify", args: args{ withAccountId: newAuthAcctWithoutVivify.PublicId, }, wantErr: true, wantErrIs: errors.RecordNotFound, }, { name: "missing auth acct id", args: args{ withAccountId: "", }, wantErr: true, wantErrIs: errors.InvalidParameter, }, { name: "existing-user-with-account-with-vivify", args: args{ withAccountId: existingUserWithAcctWithVivify.PublicId, }, wantErr: false, wantName: "existing-" + id, wantUser: user, }, { name: "existing-user-with-account-no-vivify", args: args{ withAccountId: existingUserWithAcctNoVivify.PublicId, opt: []iam.Option{}, }, wantErr: false, wantName: "existing-" + id, wantUser: user, }, { name: "bad-auth-account-id", args: args{ withAccountId: id, }, wantErr: true, wantErrIs: errors.RecordNotFound, }, { name: "bad-auth-account-id-with-vivify", args: args{ withAccountId: id, opt: []iam.Option{}, }, wantErr: true, wantErrIs: errors.RecordNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) underlyingDB, err := conn.SqlDB(ctx) require.NoError(err) dbassert := dbassert.New(t, underlyingDB) got, err := repo.LookupUserWithLogin(context.Background(), tt.args.withAccountId, tt.args.opt...) if tt.wantErr { require.Error(err) assert.Nil(got) assert.Truef(errors.Match(errors.T(tt.wantErrIs), err), "unexpected error %s", err.Error()) if tt.args.withAccountId != "" && tt.args.withAccountId != id { // need to assert that userid in auth_account is still null acct := allocAuthAccount() acct.PublicId = tt.args.withAccountId dbassert.IsNull(&acct, "IamUserId") } return } require.NoError(err) if tt.wantName != "" { assert.Equal(tt.wantName, got.Name) } if tt.wantDescription != "" { assert.Equal(tt.wantDescription, got.Description) } require.NotEmpty(got.PublicId) if tt.wantUser != nil { tt.wantUser.Version = got.User.Version tt.wantUser.CreateTime = got.User.CreateTime tt.wantUser.UpdateTime = got.User.UpdateTime assert.Empty(cmp.Diff(got.User, tt.wantUser.User, protocmp.Transform()), "got %q, wanted %q", got.User, tt.wantUser.User) } acct := allocAuthAccount() acct.PublicId = tt.args.withAccountId err = rw.LookupByPublicId(context.Background(), &acct) require.NoError(err) assert.Equal(got.PublicId, acct.IamUserId) }) } } func TestRepository_AssociateAccounts(t *testing.T) { t.Parallel() conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) wrapper := db.TestWrapper(t) kmsCache := kms.TestKms(t, conn, wrapper) repo := iam.TestRepo(t, conn, wrapper) org, _ := iam.TestScopes(t, repo) databaseWrapper, err := kmsCache.GetWrapper(context.Background(), org.PublicId, kms.KeyPurposeDatabase) require.NoError(t, err) user := iam.TestUser(t, repo, org.PublicId) createAccountsFn := func(prefix string) []string { db.TestDeleteWhere(t, conn, func() any { i := allocAuthAccount(); return &i }(), "iam_user_id = ?", user.PublicId) results := []string{} for i := 0; i < 5; i++ { authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, fmt.Sprintf("%s-alice-rp-%d", prefix, i), "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) a := oidc.TestAccount(t, conn, authMethod, fmt.Sprintf("acct-%d", i)) results = append(results, a.PublicId) } return results } type args struct { accountIdsFn func() []string userId string userVersionOverride *uint32 opt []iam.Option } tests := []struct { name string args args wantErr bool wantErrCode errors.Code }{ { name: "valid", args: args{ userId: user.PublicId, accountIdsFn: func() []string { return createAccountsFn("valid") }, }, wantErr: false, }, { name: "already-associated", args: args{ userId: user.PublicId, accountIdsFn: func() []string { ids := createAccountsFn("already-associated") authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, "already-associated-alice-rp", "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) a := oidc.TestAccount(t, conn, authMethod, "already-associated") user, _, err := repo.LookupUser(context.Background(), user.PublicId) require.NoError(t, err) added, err := repo.AddUserAccounts(context.Background(), user.PublicId, user.Version, []string{a.PublicId}) require.NoError(t, err) require.Contains(t, added, a.PublicId) ids = append(ids, a.PublicId) return ids }, }, wantErr: false, }, { name: "associated-with-diff-user", args: args{ userId: user.PublicId, accountIdsFn: func() []string { ids := createAccountsFn("associated-with-diff-user") authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, "associated-with-diff-user-alice-rp", "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) u := iam.TestUser(t, repo, org.PublicId) a := oidc.TestAccount(t, conn, authMethod, "already-associated") added, err := repo.AddUserAccounts(context.Background(), u.PublicId, u.Version, []string{a.PublicId}) require.NoError(t, err) require.Contains(t, added, a.PublicId) ids = append(ids, a.PublicId) return ids }, }, wantErr: true, wantErrCode: errors.AccountAlreadyAssociated, }, { name: "bad-version", args: args{ userVersionOverride: func() *uint32 { i := uint32(22) return &i }(), userId: user.PublicId, accountIdsFn: func() []string { return createAccountsFn("bad-version") }, }, wantErr: true, wantErrCode: errors.MultipleRecords, }, { name: "zero-version", args: args{ userVersionOverride: func() *uint32 { i := uint32(0) return &i }(), userId: user.PublicId, accountIdsFn: func() []string { return createAccountsFn("zero-version") }, }, wantErr: true, wantErrCode: errors.InvalidParameter, }, { name: "no-accounts", args: args{ userId: user.PublicId, accountIdsFn: func() []string { return nil }, }, wantErr: true, wantErrCode: errors.InvalidParameter, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) accountIds := tt.args.accountIdsFn() sort.Strings(accountIds) origUser, _, err := repo.LookupUser(context.Background(), user.PublicId) require.NoError(err) version := origUser.Version if tt.args.userVersionOverride != nil { version = *tt.args.userVersionOverride } got, err := repo.AddUserAccounts(context.Background(), tt.args.userId, version, accountIds, tt.args.opt...) if tt.wantErr { require.Error(err) assert.Truef(errors.Match(errors.T(tt.wantErrCode), err), "unexpected error %s", err) return } require.NoError(err) err = db.TestVerifyOplog(t, rw, tt.args.userId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) assert.NoError(err) for _, id := range got { err = db.TestVerifyOplog(t, rw, id, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) assert.NoError(err) } sort.Strings(got) assert.Equal(accountIds, got) foundIds, err := repo.ListUserAccounts(context.Background(), tt.args.userId) require.NoError(err) sort.Strings(foundIds) assert.Equal(accountIds, foundIds) u, _, err := repo.LookupUser(context.Background(), tt.args.userId) require.NoError(err) assert.Equal(version+1, u.Version) }) } } func TestRepository_DisassociateAccounts(t *testing.T) { t.Parallel() conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) wrapper := db.TestWrapper(t) kmsCache := kms.TestKms(t, conn, wrapper) repo := iam.TestRepo(t, conn, wrapper) org, _ := iam.TestScopes(t, repo) databaseWrapper, err := kmsCache.GetWrapper(context.Background(), org.PublicId, kms.KeyPurposeDatabase) require.NoError(t, err) user := iam.TestUser(t, repo, org.PublicId) createAccountsFn := func(prefix string) []string { db.TestDeleteWhere(t, conn, func() any { a := allocAuthAccount(); return &a }(), "iam_user_id = ?", user.PublicId) results := []string{} for i := 0; i < 1; i++ { authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, fmt.Sprintf("%s-alice-rp-%d", prefix, i), "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) a := oidc.TestAccount(t, conn, authMethod, fmt.Sprintf("acct-%d", i)) user, _, err := repo.LookupUser(context.Background(), user.PublicId) require.NoError(t, err) added, err := repo.AddUserAccounts(context.Background(), user.PublicId, user.Version, []string{a.PublicId}) require.NoError(t, err) require.Contains(t, added, a.PublicId) results = append(results, a.PublicId) } return results } type args struct { accountIdsFn func() []string userId string userVersionOverride *uint32 opt []iam.Option } tests := []struct { name string args args wantErr bool wantErrCode errors.Code }{ { name: "valid", args: args{ userId: user.PublicId, accountIdsFn: func() []string { return createAccountsFn("valid") }, }, wantErr: false, }, { name: "associated-with-diff-user", args: args{ userId: user.PublicId, accountIdsFn: func() []string { ids := createAccountsFn("associated-with-diff-user") authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, "associated-with-diff-user-alice-rp", "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) u := iam.TestUser(t, repo, org.PublicId) a := oidc.TestAccount(t, conn, authMethod, "already-associated") added, err := repo.AddUserAccounts(context.Background(), u.PublicId, u.Version, []string{a.PublicId}) require.NoError(t, err) require.Contains(t, added, a.PublicId) ids = append(ids, a.PublicId) return ids }, }, wantErr: true, wantErrCode: errors.AccountAlreadyAssociated, }, { name: "bad-version", args: args{ userVersionOverride: func() *uint32 { i := uint32(22) return &i }(), userId: user.PublicId, accountIdsFn: func() []string { return createAccountsFn("bad-version") }, }, wantErr: true, wantErrCode: errors.MultipleRecords, }, { name: "zero-version", args: args{ userVersionOverride: func() *uint32 { i := uint32(0) return &i }(), userId: user.PublicId, accountIdsFn: func() []string { return createAccountsFn("zero-version") }, }, wantErr: true, wantErrCode: errors.InvalidParameter, }, { name: "no-accounts", args: args{ userId: user.PublicId, accountIdsFn: func() []string { return nil }, }, wantErr: true, wantErrCode: errors.InvalidParameter, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) accountIds := tt.args.accountIdsFn() origUser, _, err := repo.LookupUser(context.Background(), user.PublicId) require.NoError(err) version := origUser.Version if tt.args.userVersionOverride != nil { version = *tt.args.userVersionOverride } got, err := repo.DeleteUserAccounts(context.Background(), tt.args.userId, version, accountIds, tt.args.opt...) if tt.wantErr { require.Error(err) assert.Truef(errors.Match(errors.T(tt.wantErrCode), err), "unexpected error %s", err) return } require.NoError(err) err = db.TestVerifyOplog(t, rw, tt.args.userId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) assert.NoError(err) for _, id := range got { err = db.TestVerifyOplog(t, rw, id, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) assert.NoError(err) } foundIds, err := repo.ListUserAccounts(context.Background(), tt.args.userId) require.NoError(err) for _, id := range accountIds { assert.True(!strutil.StrListContains(foundIds, id)) } u, _, err := repo.LookupUser(context.Background(), tt.args.userId) require.NoError(err) assert.Equal(version+1, u.Version) }) } } func TestRepository_SetAssociatedAccounts(t *testing.T) { t.Parallel() conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) wrapper := db.TestWrapper(t) kmsCache := kms.TestKms(t, conn, wrapper) repo := iam.TestRepo(t, conn, wrapper) org, _ := iam.TestScopes(t, repo) databaseWrapper, err := kmsCache.GetWrapper(context.Background(), org.PublicId, kms.KeyPurposeDatabase) require.NoError(t, err) user := iam.TestUser(t, repo, org.PublicId) createAccountsFn := func(prefix string) []string { db.TestDeleteWhere(t, conn, func() any { i := allocAuthAccount(); return &i }(), "iam_user_id = ?", user.PublicId) results := []string{} for i := 0; i < 1; i++ { authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, fmt.Sprintf("%s-alice-rp-%d", prefix, i), "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) a := oidc.TestAccount(t, conn, authMethod, fmt.Sprintf("acct-%d", i)) results = append(results, a.PublicId) } return results } type args struct { accountIdsFn func() ([]string, []string) userId string userVersionOverride *uint32 opt []iam.Option } tests := []struct { name string args args wantErr bool wantErrCode errors.Code }{ { name: "valid", args: args{ userId: user.PublicId, accountIdsFn: func() ([]string, []string) { ids := createAccountsFn("valid") return ids, ids }, }, wantErr: false, }, { name: "one-already-associated", args: args{ userId: user.PublicId, accountIdsFn: func() ([]string, []string) { ids := createAccountsFn("one-already-associated") changes := append([]string{}, ids...) authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, "one-already-associated-alice-rp", "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) a := oidc.TestAccount(t, conn, authMethod, "acct-one-already-associated") user, _, err := repo.LookupUser(context.Background(), user.PublicId) require.NoError(t, err) added, err := repo.AddUserAccounts(context.Background(), user.PublicId, user.Version, []string{a.PublicId}) require.NoError(t, err) require.Contains(t, added, a.PublicId) ids = append(ids, a.PublicId) return ids, changes }, }, wantErr: false, }, { name: "no-change", args: args{ userId: user.PublicId, accountIdsFn: func() ([]string, []string) { ids := createAccountsFn("no-change") user, _, err := repo.LookupUser(context.Background(), user.PublicId) require.NoError(t, err) added, err := repo.AddUserAccounts(context.Background(), user.PublicId, user.Version, ids) require.NoError(t, err) require.Equal(t, len(ids), len(added)) // ids := []string{} // for i := 0; i < 10; i++ { // a := testAccount(t, conn, org.PublicId, authMethodId, user.PublicId) // ids = append(ids, a.PublicId) // } return ids, nil }, }, wantErr: false, }, { name: "remove-all", args: args{ userId: user.PublicId, accountIdsFn: func() ([]string, []string) { ids := createAccountsFn("remove-all") user, _, err := repo.LookupUser(context.Background(), user.PublicId) require.NoError(t, err) added, err := repo.AddUserAccounts(context.Background(), user.PublicId, user.Version, ids) require.NoError(t, err) require.Equal(t, len(ids), len(added)) return nil, ids }, }, wantErr: false, }, { name: "associated-with-diff-user", args: args{ userId: user.PublicId, accountIdsFn: func() ([]string, []string) { ids := createAccountsFn("associated-with-diff-user") authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, "associated-with-diff-user-alice-rp", "fido", oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://alice.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "http://localhost")[0])) u := iam.TestUser(t, repo, org.PublicId) a := oidc.TestAccount(t, conn, authMethod, "already-associated") added, err := repo.AddUserAccounts(context.Background(), u.PublicId, u.Version, []string{a.PublicId}) require.NoError(t, err) require.Contains(t, added, a.PublicId) ids = append(ids, a.PublicId) return ids, ids }, }, wantErr: true, wantErrCode: errors.AccountAlreadyAssociated, }, { name: "bad-version", args: args{ userVersionOverride: func() *uint32 { i := uint32(22) return &i }(), userId: user.PublicId, accountIdsFn: func() ([]string, []string) { ids := createAccountsFn("bad-version") return ids, ids }, }, wantErr: true, wantErrCode: errors.MultipleRecords, }, { name: "zero-version", args: args{ userVersionOverride: func() *uint32 { i := uint32(0) return &i }(), userId: user.PublicId, accountIdsFn: func() ([]string, []string) { ids := createAccountsFn("zero-version") return ids, ids }, }, wantErr: true, wantErrCode: errors.InvalidParameter, }, { name: "no-accounts-no-changes", args: args{ userId: user.PublicId, accountIdsFn: func() ([]string, []string) { return nil, nil }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) db.TestDeleteWhere(t, conn, func() any { a := allocAuthAccount(); return &a }(), "iam_user_id = ?", user.PublicId) accountIds, changes := tt.args.accountIdsFn() sort.Strings(accountIds) origUser, _, err := repo.LookupUser(context.Background(), user.PublicId) require.NoError(err) version := origUser.Version if tt.args.userVersionOverride != nil { version = *tt.args.userVersionOverride } got, err := repo.SetUserAccounts(context.Background(), tt.args.userId, version, accountIds, tt.args.opt...) if tt.wantErr { require.Error(err) assert.Truef(errors.Match(errors.T(tt.wantErrCode), err), "unexpected error %s", err) return } require.NoError(err) if len(changes) != 0 { err = db.TestVerifyOplog(t, rw, tt.args.userId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) assert.NoError(err) for _, id := range changes { err = db.TestVerifyOplog(t, rw, id, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second)) assert.NoErrorf(err, "%s missing oplog entry", id) } } sort.Strings(got) assert.Equal(accountIds, got) foundIds, err := repo.ListUserAccounts(context.Background(), tt.args.userId) require.NoError(err) sort.Strings(foundIds) assert.Equal(accountIds, foundIds) u, _, err := repo.LookupUser(context.Background(), tt.args.userId) require.NoError(err) switch tt.name { case "no-accounts-no-changes", "no-change": assert.Equalf(version, u.Version, "expected version %d and got: %d", version, u.Version) default: assert.Equalf(version+1, u.Version, "expected version %d and got: %d", version+1, u.Version) } }) } } func testId(t *testing.T) string { t.Helper() id, err := uuid.GenerateUUID() require.NoError(t, err) return id } type testAuthAccount struct { *store.Account tableName string `gorm:"-"` } func allocAuthAccount() testAuthAccount { return testAuthAccount{ Account: &store.Account{}, } } // TableName returns the tablename to override the default gorm table name. func (a *testAuthAccount) TableName() string { if a.tableName != "" { return a.tableName } return "auth_account" }