Skip to content

Commit f4ef2d1

Browse files
committed
avoid duplicate imported profiles and auth
1 parent 41ac86d commit f4ef2d1

5 files changed

Lines changed: 243 additions & 33 deletions

File tree

cmd/browser_import_managed_auth.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ type managedAuthProvisioner interface {
7575
Existing(context.Context, string, []passwordmanager.Candidate) (map[string]bool, error)
7676
}
7777

78+
type crossProfileManagedAuthFinder interface {
79+
ExistingProfiles(context.Context, string, []passwordmanager.Candidate) (map[string][]string, error)
80+
}
81+
7882
type kernelManagedAuthProvisioner struct {
7983
credentials interface {
8084
New(context.Context, kernel.CredentialNewParams, ...option.RequestOption) (*kernel.Credential, error)
@@ -168,6 +172,34 @@ func (p kernelManagedAuthProvisioner) Existing(ctx context.Context, profileName
168172
return result, nil
169173
}
170174

175+
func (p kernelManagedAuthProvisioner) ExistingProfiles(ctx context.Context, profileName string, candidates []passwordmanager.Candidate) (map[string][]string, error) {
176+
profilesByCredential := make(map[string][]string)
177+
const pageSize = 100
178+
for offset := int64(0); ; offset += pageSize {
179+
page, err := p.connections.List(ctx, kernel.AuthConnectionListParams{Limit: kernel.Opt(int64(pageSize)), Offset: kernel.Opt(offset)})
180+
if err != nil {
181+
return nil, err
182+
}
183+
if page == nil {
184+
break
185+
}
186+
for _, connection := range page.Items {
187+
if connection.ProfileName != profileName {
188+
profilesByCredential[connection.Credential.Name] = append(profilesByCredential[connection.Credential.Name], connection.ProfileName)
189+
}
190+
}
191+
if len(page.Items) < pageSize {
192+
break
193+
}
194+
}
195+
result := make(map[string][]string, len(candidates))
196+
for _, candidate := range candidates {
197+
name := importedCredentialNameFor(candidate.Provider, candidateImportID(candidate), candidate.Domain)
198+
result[candidateKey(candidate)] = profilesByCredential[name]
199+
}
200+
return result, nil
201+
}
202+
171203
type connectionLookup struct {
172204
match *kernel.ManagedAuth
173205
conflict *kernel.ManagedAuth

cmd/browser_import_managed_auth_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ func TestManagedAuthExistingUsesOnePasswordVaultIdentity(t *testing.T) {
160160
assert.True(t, existing[candidateKey(candidate)])
161161
}
162162

163+
func TestManagedAuthExistingProfilesFindsSameAccountOnAnotherProfile(t *testing.T) {
164+
candidate := passwordmanager.Candidate{Provider: "bitwarden", ID: "item", Domain: "google.com"}
165+
name := importedCredentialNameFor("bitwarden", "item", "google.com")
166+
provisioner := kernelManagedAuthProvisioner{connections: fakeImportedConnections{
167+
listFunc: func(params kernel.AuthConnectionListParams) (*pagination.OffsetPagination[kernel.ManagedAuth], error) {
168+
require.False(t, params.ProfileName.Valid())
169+
return &pagination.OffsetPagination[kernel.ManagedAuth]{Items: []kernel.ManagedAuth{
170+
{ProfileName: "helium-you", Credential: kernel.ManagedAuthCredential{Name: name}},
171+
{ProfileName: "helium-you-2", Credential: kernel.ManagedAuthCredential{Name: "another-account"}},
172+
}}, nil
173+
},
174+
}}
175+
176+
profiles, err := provisioner.ExistingProfiles(t.Context(), "helium-you-2", []passwordmanager.Candidate{candidate})
177+
require.NoError(t, err)
178+
assert.Equal(t, []string{"helium-you"}, profiles[candidateKey(candidate)])
179+
}
180+
163181
func TestManagedAuthProvisionFindsMatchingConnectionAfterSiblingAccount(t *testing.T) {
164182
record := passwordmanager.Record{Provider: "bitwarden", ID: "item", Domain: "example.com", Username: "me"}
165183
name := importedCredentialName(record)

cmd/profiles_import_local.go

Lines changed: 113 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,20 @@ type ProfilesImportLocalInput struct {
5454
}
5555

5656
type ProfilesImportLocalCmd struct {
57-
prompter interactive.Prompter
58-
homeDir func() (string, error)
59-
now func() time.Time
60-
providers func() []passwordmanager.Provider
61-
provisioner managedAuthProvisioner
62-
managedAuthCapacity func(context.Context) (managedAuthCapacity, error)
63-
profileExists func(context.Context, string) (bool, error)
57+
prompter interactive.Prompter
58+
homeDir func() (string, error)
59+
now func() time.Time
60+
providers func() []passwordmanager.Provider
61+
provisioner managedAuthProvisioner
62+
managedAuthCapacity func(context.Context) (managedAuthCapacity, error)
63+
profileLookup func(context.Context, string) (kernelProfileReference, bool, error)
64+
selectProfileTarget func(string, []string, string) (string, error)
65+
selectManagedAuthAccount func(string, []string, string) (string, error)
66+
}
67+
68+
type kernelProfileReference struct {
69+
ID string
70+
Name string
6471
}
6572

6673
type pendingManagedAuth struct {
@@ -195,22 +202,18 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI
195202
pterm.Success.Printf("Found %s\n", profile.DisplayName())
196203
}
197204
targetName := in.ProfileName
198-
explicitProfileName := targetName != ""
205+
targetProfileID := ""
199206
if targetName == "" {
200207
targetName = defaultImportedProfileName(profile)
201208
}
202209
if targetName == "" || len(targetName) > 255 || profileIDNameCharacters.MatchString(targetName) || cuidLikeProfileName.MatchString(targetName) {
203210
return fmt.Errorf("profile name must be 1-255 letters, numbers, dots, underscores, or hyphens and cannot be a cuid-like string")
204211
}
205-
if c.profileExists != nil {
206-
resolvedName, renamed, err := resolveImportedProfileName(ctx, targetName, explicitProfileName, c.profileExists)
212+
if c.profileLookup != nil {
213+
targetName, targetProfileID, err = c.chooseImportedProfileTarget(ctx, profile, targetName, nonInteractive)
207214
if err != nil {
208215
return err
209216
}
210-
if renamed && humanOutput {
211-
pterm.Info.Printf("Kernel profile %q already exists; using %q for this import\n", targetName, resolvedName)
212-
}
213-
targetName = resolvedName
214217
}
215218
explicitSites := in.Sites
216219
if len(explicitSites) > 0 {
@@ -399,7 +402,7 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI
399402
ID: profile.ID, Kind: "browser", Name: profile.DisplayName(), Browser: profile.Browser.ID,
400403
DataTypes: categories, ItemCounts: itemCounts,
401404
}}}
402-
selection := localbrowser.Selection{Profiles: []localbrowser.ProfileSelection{{SourceID: profile.ID, TargetName: targetName, Categories: categories}}, CredentialSources: make([]string, 0)}
405+
selection := localbrowser.Selection{Profiles: []localbrowser.ProfileSelection{{SourceID: profile.ID, TargetName: targetName, TargetProfileID: targetProfileID, Categories: categories}}, CredentialSources: make([]string, 0)}
403406
profileJob := startProfileImport(ctx, client, profileImportRequest{
404407
importID: importID, helperToken: helperToken, dashboardHandoff: dashboardHandoff,
405408
inventory: inventory, selection: selection, bundle: bundle, waitTimeout: in.WaitTimeout,
@@ -776,12 +779,21 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro
776779
}
777780
return pendingManagedAuth{}, nil
778781
}
782+
otherProfiles := make(map[string][]string)
783+
if finder, ok := c.provisioner.(crossProfileManagedAuthFinder); ok {
784+
otherProfiles, err = finder.ExistingProfiles(ctx, profileName, candidates)
785+
if err != nil {
786+
return pendingManagedAuth{}, fmt.Errorf("check Managed Auth connections on other profiles: %w", err)
787+
}
788+
}
779789
hasExisting := false
780790
hasNew := false
781791
for _, candidate := range candidates {
782-
if existing[candidateKey(candidate)] {
792+
key := candidateKey(candidate)
793+
if existing[key] || len(otherProfiles[key]) > 0 {
783794
hasExisting = true
784-
} else {
795+
}
796+
if !existing[key] {
785797
hasNew = true
786798
}
787799
}
@@ -915,7 +927,7 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro
915927
approvedCandidates = append(approvedCandidates, sourced)
916928
}
917929
} else {
918-
approvedCandidates, err = c.chooseManagedAuthAccountsByWebsite(sites, allCandidates, existing, availableConnections, 0, displayCapacity, displayCapacityKnown)
930+
approvedCandidates, err = c.chooseManagedAuthAccountsByWebsite(profileName, sites, allCandidates, existing, otherProfiles, availableConnections, 0, displayCapacity, displayCapacityKnown)
919931
if err != nil {
920932
return pendingManagedAuth{}, err
921933
}
@@ -939,7 +951,7 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro
939951
return pending, nil
940952
}
941953

942-
func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []string, candidates []sourcedPasswordManagerCandidate, existing map[string]bool, availableConnections, alreadySelectedNew int, capacity managedAuthCapacity, capacityKnown bool) ([]sourcedPasswordManagerCandidate, error) {
954+
func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(profileName string, sites []string, candidates []sourcedPasswordManagerCandidate, existing map[string]bool, otherProfiles map[string][]string, availableConnections, alreadySelectedNew int, capacity managedAuthCapacity, capacityKnown bool) ([]sourcedPasswordManagerCandidate, error) {
943955
byDomain := make(map[string][]sourcedPasswordManagerCandidate, len(sites))
944956
for _, candidate := range candidates {
945957
byDomain[candidate.candidate.Domain] = append(byDomain[candidate.candidate.Domain], candidate)
@@ -958,7 +970,7 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []strin
958970
for domainIndex := 0; domainIndex < len(domains); {
959971
domain := domains[domainIndex]
960972
domainCandidates := byDomain[domain]
961-
if len(domainCandidates) == 1 {
973+
if len(domainCandidates) == 1 && len(otherProfiles[candidateKey(domainCandidates[0].candidate)]) == 0 {
962974
chosen := domainCandidates[0]
963975
delete(choices, domain)
964976
if !existing[candidateKey(chosen.candidate)] && managedAuthCapacityReached(alreadySelectedNew+managedAuthNewChoiceCount(choices, existing), availableConnections, capacity) {
@@ -971,16 +983,38 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []strin
971983
domainIndex++
972984
continue
973985
}
974-
labels := make([]string, 0, len(domainCandidates))
986+
labels := make([]string, 0, len(domainCandidates)*2)
975987
byLabel := make(map[string]sourcedPasswordManagerCandidate, len(domainCandidates))
988+
keepExisting := make(map[string]bool)
976989
existingLabels := make([]string, 0, len(domainCandidates))
977-
labels = groupedLoginLabels(domainCandidates)
990+
candidateLabels := groupedLoginLabels(domainCandidates)
991+
accountPrompt := domain + " — ↑/↓ move, Enter chooses"
992+
if len(domainCandidates) == 1 {
993+
profiles := otherProfiles[candidateKey(domainCandidates[0].candidate)]
994+
if len(profiles) > 0 && !existing[candidateKey(domainCandidates[0].candidate)] {
995+
pterm.Printf("%s\nAlready managed on %q with this account\n\n", domain, profiles[0])
996+
accountPrompt = "Choose how to use this account — ↑/↓ move, Enter chooses"
997+
}
998+
}
978999
for index, sourced := range domainCandidates {
979-
label := labels[index]
1000+
label := candidateLabels[index]
1001+
profiles := otherProfiles[candidateKey(sourced.candidate)]
1002+
if len(profiles) > 0 && !existing[candidateKey(sourced.candidate)] {
1003+
keepLabel := fmt.Sprintf("Keep this account managed on %q · 0 new slots", profiles[0])
1004+
alsoLabel := fmt.Sprintf("Also manage this account on %q · uses 1 slot", profileName)
1005+
if len(domainCandidates) > 1 {
1006+
keepLabel = fmt.Sprintf("Keep %s on %q · 0 new slots", label, profiles[0])
1007+
alsoLabel = fmt.Sprintf("Also manage %s on %q · uses 1 slot", label, profileName)
1008+
}
1009+
labels = append(labels, keepLabel, alsoLabel)
1010+
keepExisting[keepLabel] = true
1011+
byLabel[alsoLabel] = sourced
1012+
continue
1013+
}
9801014
if existing[candidateKey(sourced.candidate)] {
9811015
label += " ✓ existing · no new slot"
982-
labels[index] = label
9831016
}
1017+
labels = append(labels, label)
9841018
byLabel[label] = sourced
9851019
if existing[candidateKey(sourced.candidate)] {
9861020
existingLabels = append(existingLabels, label)
@@ -1004,7 +1038,13 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []strin
10041038
} else if managedAuthCapacityReached(alreadySelectedNew+managedAuthNewChoiceCount(choices, existing), availableConnections, capacity) {
10051039
defaultOption = "Skip this website"
10061040
}
1007-
selected, err := c.prompter.SelectDefault("login for "+domain, "use arrow keys and press Enter", domain+" — ↑/↓ move, Enter chooses", options, defaultOption)
1041+
var selected string
1042+
var err error
1043+
if c.selectManagedAuthAccount != nil {
1044+
selected, err = c.selectManagedAuthAccount(domain, options, defaultOption)
1045+
} else {
1046+
selected, err = c.prompter.SelectDefault("login for "+domain, "use arrow keys and press Enter", accountPrompt, options, defaultOption)
1047+
}
10081048
if err != nil {
10091049
return nil, err
10101050
}
@@ -1024,6 +1064,11 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []strin
10241064
domainIndex++
10251065
continue
10261066
}
1067+
if keepExisting[selected] {
1068+
delete(choices, domain)
1069+
domainIndex++
1070+
continue
1071+
}
10271072
chosen := byLabel[selected]
10281073
previous, hadPrevious := choices[domain]
10291074
delete(choices, domain)
@@ -1447,6 +1492,44 @@ func resolveImportedProfileName(ctx context.Context, requested string, explicit
14471492
return "", false, fmt.Errorf("could not find an available Kernel profile name based on %q; use --profile-name", requested)
14481493
}
14491494

1495+
func (c ProfilesImportLocalCmd) chooseImportedProfileTarget(ctx context.Context, source localbrowser.Profile, requested string, nonInteractive bool) (string, string, error) {
1496+
existing, found, err := c.profileLookup(ctx, requested)
1497+
if err != nil {
1498+
return "", "", fmt.Errorf("check Kernel profile name %q: %w", requested, err)
1499+
}
1500+
if !found {
1501+
return requested, "", nil
1502+
}
1503+
if nonInteractive {
1504+
return "", "", fmt.Errorf("Kernel profile %q already exists; run interactively to update it or choose a different --profile-name", requested)
1505+
}
1506+
1507+
separateName, _, err := resolveImportedProfileName(ctx, requested, false, func(ctx context.Context, name string) (bool, error) {
1508+
_, found, err := c.profileLookup(ctx, name)
1509+
return found, err
1510+
})
1511+
if err != nil {
1512+
return "", "", err
1513+
}
1514+
updateOption := fmt.Sprintf("Update %q (recommended; keeps existing Managed Auth connections)", existing.Name)
1515+
separateOption := fmt.Sprintf("Create a separate profile %q", separateName)
1516+
prompt := fmt.Sprintf("An earlier %s import already exists", source.Browser.Name)
1517+
options := []string{updateOption, separateOption}
1518+
var choice string
1519+
if c.selectProfileTarget != nil {
1520+
choice, err = c.selectProfileTarget(prompt, options, updateOption)
1521+
} else {
1522+
choice, err = c.prompter.SelectDefault("profile import destination", "choose whether to update or create a separate profile", prompt, options, updateOption)
1523+
}
1524+
if err != nil {
1525+
return "", "", err
1526+
}
1527+
if choice == updateOption {
1528+
return existing.Name, existing.ID, nil
1529+
}
1530+
return separateName, "", nil
1531+
}
1532+
14501533
func durationMilliseconds(values map[string]time.Duration) map[string]int64 {
14511534
result := make(map[string]int64, len(values))
14521535
for name, duration := range values {
@@ -1943,16 +2026,16 @@ func runProfilesImportLocalWithInput(cmd *cobra.Command, input ProfilesImportLoc
19432026
providers: passwordmanager.Detect,
19442027
provisioner: kernelManagedAuthProvisioner{credentials: &credentials, connections: &connections},
19452028
managedAuthCapacity: func(ctx context.Context) (managedAuthCapacity, error) { return loadManagedAuthCapacity(ctx, &limits) },
1946-
profileExists: func(ctx context.Context, name string) (bool, error) {
1947-
_, err := profiles.Get(ctx, name)
2029+
profileLookup: func(ctx context.Context, name string) (kernelProfileReference, bool, error) {
2030+
profile, err := profiles.Get(ctx, name)
19482031
if err == nil {
1949-
return true, nil
2032+
return kernelProfileReference{ID: profile.ID, Name: profile.Name}, true, nil
19502033
}
19512034
var apiErr *kernel.Error
19522035
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
1953-
return false, nil
2036+
return kernelProfileReference{}, false, nil
19542037
}
1955-
return false, util.CleanedUpSdkError{Err: err}
2038+
return kernelProfileReference{}, false, util.CleanedUpSdkError{Err: err}
19562039
},
19572040
}
19582041
input.ProjectID = project.ID

0 commit comments

Comments
 (0)