2014 lines
64 KiB
Go
2014 lines
64 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
var (
|
|
profilesAcquireDualLock = AcquireDualLock
|
|
profilesReadFile = os.ReadFile
|
|
profilesRename = os.Rename
|
|
profilesMkdirAll = os.MkdirAll
|
|
profilesMarshalIndent = json.MarshalIndent
|
|
profilesWriteFile = os.WriteFile
|
|
profilesRemove = os.Remove
|
|
profilesLoad = LoadProfiles
|
|
profilesSave = SaveProfiles
|
|
profilesEnsureMigration = ensureProfilesMigrationLocked
|
|
profilesSyncLegacyMirror = syncLegacyTokenMirrorLocked
|
|
profilesTokenExists = TokenDataExistsKeychain
|
|
profilesLoadLegacy = LoadTokenDataKeychain
|
|
profilesSaveCorp = SaveTokenDataKeychainForCorpID
|
|
profilesSaveIdentity = SaveTokenDataKeychainForIdentity
|
|
profilesTokenExistsCorp = TokenDataExistsKeychainForCorpID
|
|
profilesLoadCorp = LoadTokenDataKeychainForCorpID
|
|
profilesDeleteCorp = DeleteTokenDataKeychainForCorpID
|
|
profilesLoadIdentity = LoadTokenDataKeychainForIdentity
|
|
profilesSaveLegacy = SaveTokenDataKeychain
|
|
profilesWriteMarker = WriteTokenMarker
|
|
profilesWriteManualMarker = WriteManualTokenMarker
|
|
profilesDeleteLegacy = DeleteTokenDataKeychain
|
|
profilesDeleteMarker = DeleteTokenMarker
|
|
)
|
|
|
|
// withProfilesLock runs fn while holding the auth dual-layer lock (process +
|
|
// cross-process file lock) so that all read-modify-write cycles on
|
|
// profiles.json and the legacy token mirror are serialized.
|
|
//
|
|
// The lock is NOT reentrant. fn must only call the lock-free *Locked variants;
|
|
// calling a public (locking) function from within fn would deadlock. Paths that
|
|
// already hold the lock (e.g. OAuthProvider.lockedRefresh and the read path
|
|
// reached from it) must likewise call the lock-free variants directly.
|
|
func withProfilesLock(configDir string, fn func() error) error {
|
|
lock, err := profilesAcquireDualLock(context.Background(), configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer lock.Release()
|
|
return fn()
|
|
}
|
|
|
|
const (
|
|
profilesJSONFile = "profiles.json"
|
|
profilesVersion = 2
|
|
profilesUnresolvedSelectorVersion = 3
|
|
profilesMaxVersion = profilesUnresolvedSelectorVersion
|
|
unresolvedProfileSelectorPrefix = "@legacy/"
|
|
)
|
|
|
|
const (
|
|
ProfileStatusActive = "active"
|
|
ProfileStatusExpired = "expired"
|
|
ProfileStatusRevoked = "revoked"
|
|
ProfileStatusUnavailable = "unavailable"
|
|
)
|
|
|
|
// ProfilesConfig stores non-sensitive profile metadata. Token material stays in keychain.
|
|
type ProfilesConfig struct {
|
|
Version int `json:"version"`
|
|
PrimaryProfile string `json:"primaryProfile,omitempty"`
|
|
CurrentProfile string `json:"currentProfile,omitempty"`
|
|
PreviousProfile string `json:"previousProfile,omitempty"`
|
|
OrgCurrentProfiles map[string]string `json:"orgCurrentProfiles,omitempty"`
|
|
Profiles []Profile `json:"profiles,omitempty"`
|
|
}
|
|
|
|
// Profile is a logged-in DingTalk organization identity.
|
|
type Profile struct {
|
|
Name string `json:"name"`
|
|
CorpID string `json:"corpId"`
|
|
CorpName string `json:"corpName,omitempty"`
|
|
UserID string `json:"userId,omitempty"`
|
|
UserName string `json:"userName,omitempty"`
|
|
ClientID string `json:"clientId,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
|
|
ExpiresAt string `json:"expiresAt,omitempty"`
|
|
RefreshExpAt string `json:"refreshExpAt,omitempty"`
|
|
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
|
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
// SetRuntimeProfile sets a process-local one-shot profile override.
|
|
func SetRuntimeProfile(profile string) {
|
|
profilectx.Set(profile)
|
|
}
|
|
|
|
// RuntimeProfile returns the process-local one-shot profile override.
|
|
func RuntimeProfile() string {
|
|
return profilectx.Get()
|
|
}
|
|
|
|
// ProfilesPath returns the profile metadata path for a config dir.
|
|
func ProfilesPath(configDir string) string {
|
|
return filepath.Join(configDir, profilesJSONFile)
|
|
}
|
|
|
|
// LoadProfiles reads profiles.json. A missing file returns an empty config.
|
|
func LoadProfiles(configDir string) (*ProfilesConfig, error) {
|
|
path := ProfilesPath(configDir)
|
|
data, err := profilesReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &ProfilesConfig{Version: 1}, nil
|
|
}
|
|
return nil, fmt.Errorf("read profiles: %w", err)
|
|
}
|
|
var cfg ProfilesConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
// Corrupt file (e.g. an interrupted concurrent write): quarantine it and
|
|
// rebuild an empty config so the CLI can self-heal (auth reset / re-login)
|
|
// instead of being permanently locked out by an unreadable profiles.json.
|
|
quarantine := path + ".corrupt-" + time.Now().Format("20060102-150405.000")
|
|
_ = profilesRename(path, quarantine)
|
|
return &ProfilesConfig{Version: 1}, nil
|
|
}
|
|
normalizeProfilesConfig(&cfg)
|
|
return &cfg, nil
|
|
}
|
|
|
|
// SaveProfiles writes profiles.json atomically.
|
|
func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
|
|
if cfg == nil {
|
|
cfg = &ProfilesConfig{}
|
|
}
|
|
if err := ensureProfilesWritable(cfg); err != nil {
|
|
return err
|
|
}
|
|
normalizeProfilesConfig(cfg)
|
|
normalizeProfilesVersionForSelectors(cfg)
|
|
if err := profilesMkdirAll(configDir, config.DirPerm); err != nil {
|
|
return fmt.Errorf("create config dir: %w", err)
|
|
}
|
|
data, err := profilesMarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal profiles: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
path := ProfilesPath(configDir)
|
|
// Per-write random temp name: a fixed "profiles.json.tmp" lets two
|
|
// concurrent writers interleave into the same temp file and rename a
|
|
// corrupted result into place.
|
|
tmp := path + "." + uuid.New().String() + ".tmp"
|
|
if err := profilesWriteFile(tmp, data, config.FilePerm); err != nil {
|
|
return fmt.Errorf("write profiles tmp: %w", err)
|
|
}
|
|
if err := profilesRename(tmp, path); err != nil {
|
|
_ = profilesRemove(tmp)
|
|
return fmt.Errorf("rename profiles: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed.
|
|
// EnsureProfilesMigration migrates a legacy single-slot token into the
|
|
// profiles registry. It acquires the lock; call ensureProfilesMigrationLocked
|
|
// from contexts that already hold it (refresh / read paths).
|
|
func EnsureProfilesMigration(configDir string) error {
|
|
return withProfilesLock(configDir, func() error {
|
|
return ensureProfilesMigrationLocked(configDir)
|
|
})
|
|
}
|
|
|
|
func ensureProfilesMigrationLocked(configDir string) error {
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg.Version > profilesMaxVersion {
|
|
return nil
|
|
}
|
|
if len(cfg.Profiles) == 0 {
|
|
// Version 2 with no profiles is an intentional logged-out tombstone.
|
|
// Never resurrect a stale legacy mirror after logout/reset.
|
|
if cfg.Version >= profilesVersion {
|
|
if normalizeProfilesVersionForSelectors(cfg) {
|
|
return profilesSave(configDir, cfg)
|
|
}
|
|
return nil
|
|
}
|
|
if !profilesTokenExists() {
|
|
return nil
|
|
}
|
|
data, loadErr := profilesLoadLegacy()
|
|
if loadErr != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
|
|
return nil
|
|
}
|
|
if err := profilesSaveCorp(data.CorpID, data); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(data.UserID) != "" {
|
|
if err := profilesSaveIdentity(data.CorpID, data.UserID, data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return upsertProfileFromToken(configDir, cfg, data, false)
|
|
}
|
|
|
|
legacySelectionState := cfg.Version < profilesVersion
|
|
changed := false
|
|
if cfg.OrgCurrentProfiles == nil {
|
|
cfg.OrgCurrentProfiles = make(map[string]string)
|
|
}
|
|
orgTokens := make(map[string]*TokenData)
|
|
var legacyToken *TokenData
|
|
var legacyTokenErr error
|
|
legacyTokenLoaded := false
|
|
for i := range cfg.Profiles {
|
|
p := &cfg.Profiles[i]
|
|
corpID := strings.TrimSpace(p.CorpID)
|
|
if corpID == "" {
|
|
continue
|
|
}
|
|
orgToken, loaded := orgTokens[corpID]
|
|
if !loaded {
|
|
token, loadErr := profilesLoadCorp(corpID)
|
|
if legacySelectionState && loadErr != nil && !errors.Is(loadErr, ErrTokenDataNotFound) {
|
|
return loadErr
|
|
}
|
|
if loadErr != nil {
|
|
token = nil
|
|
}
|
|
var v2RepairProfile *Profile
|
|
if !legacySelectionState && errors.Is(loadErr, ErrTokenDataNotFound) {
|
|
v2RepairProfile = uniqueV2GlobalRepairProfile(cfg, corpID)
|
|
if v2RepairProfile != nil && strings.TrimSpace(v2RepairProfile.UserID) != "" {
|
|
_, identityErr := profilesLoadIdentity(corpID, v2RepairProfile.UserID)
|
|
switch {
|
|
case identityErr == nil:
|
|
// The exact identity is already usable. Do not let a stale
|
|
// global compatibility mirror recreate the organization slot.
|
|
v2RepairProfile = nil
|
|
case !errors.Is(identityErr, ErrTokenDataNotFound):
|
|
return identityErr
|
|
}
|
|
}
|
|
}
|
|
if (legacySelectionState || v2RepairProfile != nil) && errors.Is(loadErr, ErrTokenDataNotFound) {
|
|
// v1.0.50/1.0.51 installations can retain the selected
|
|
// organization only in the global compatibility slot. Consult
|
|
// that slot while migrating v1, or while repairing a non-empty
|
|
// v2 registry left half-migrated by an earlier CLI. The v2 path
|
|
// additionally requires one unambiguous profile, a missing exact
|
|
// slot when its userId is known, and a non-conflicting token userId.
|
|
// Persist the untouched organization mirror before identity
|
|
// enrichment below.
|
|
if !legacyTokenLoaded {
|
|
legacyToken, legacyTokenErr = profilesLoadLegacy()
|
|
legacyTokenLoaded = true
|
|
}
|
|
if legacyTokenErr != nil && !errors.Is(legacyTokenErr, ErrTokenDataNotFound) {
|
|
return legacyTokenErr
|
|
}
|
|
legacyMatchesProfile := legacySelectionState ||
|
|
legacyTokenMatchesV2RepairProfile(legacyToken, v2RepairProfile)
|
|
if legacyTokenErr == nil &&
|
|
legacyToken != nil &&
|
|
strings.TrimSpace(legacyToken.CorpID) == corpID &&
|
|
legacyMatchesProfile {
|
|
token = legacyToken
|
|
if err := profilesSaveCorp(corpID, token); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
orgToken = token
|
|
orgTokens[corpID] = orgToken
|
|
}
|
|
if orgToken == nil {
|
|
continue
|
|
}
|
|
// v1.0.52 stored one token per organization. Some of those token blobs
|
|
// predate userId persistence even though profiles.json already recorded
|
|
// the account identity. Version 2 loads exact identities and therefore
|
|
// cannot safely use an organization mirror with no userId. A single
|
|
// profile with a known userId makes that association unambiguous,
|
|
// including when an earlier migration already bumped profiles.json to v2
|
|
// but failed before writing the identity slot. Enrich only the copy saved
|
|
// to that exact slot; never infer an identity for an organization with
|
|
// multiple accounts.
|
|
identityToken := orgToken
|
|
if strings.TrimSpace(orgToken.UserID) == "" &&
|
|
strings.TrimSpace(p.UserID) != "" &&
|
|
len(profilesForCorpID(cfg, corpID)) == 1 {
|
|
enriched := *orgToken
|
|
enriched.UserID = strings.TrimSpace(p.UserID)
|
|
if strings.TrimSpace(enriched.UserName) == "" {
|
|
enriched.UserName = strings.TrimSpace(p.UserName)
|
|
}
|
|
identityToken = &enriched
|
|
}
|
|
if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(orgToken.UserID) != "" {
|
|
if existing := findExactProfile(cfg, corpID, orgToken.UserID); existing != nil && existing != p {
|
|
p.CorpID = ""
|
|
changed = true
|
|
continue
|
|
}
|
|
p.UserID = strings.TrimSpace(orgToken.UserID)
|
|
if p.UserName == "" {
|
|
p.UserName = strings.TrimSpace(orgToken.UserName)
|
|
}
|
|
changed = true
|
|
}
|
|
if strings.TrimSpace(p.UserID) == "" || strings.TrimSpace(identityToken.UserID) != strings.TrimSpace(p.UserID) {
|
|
continue
|
|
}
|
|
_, identityErr := profilesLoadIdentity(corpID, p.UserID)
|
|
if errors.Is(identityErr, ErrTokenDataNotFound) {
|
|
if err := profilesSaveIdentity(corpID, p.UserID, identityToken); err != nil {
|
|
return err
|
|
}
|
|
} else if identityErr != nil {
|
|
return identityErr
|
|
}
|
|
}
|
|
for _, corpID := range uniqueProfileCorpIDs(cfg) {
|
|
if exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) != "" {
|
|
continue
|
|
}
|
|
if _, exists := cfg.OrgCurrentProfiles[corpID]; exists {
|
|
delete(cfg.OrgCurrentProfiles, corpID)
|
|
changed = true
|
|
}
|
|
if !legacySelectionState {
|
|
continue
|
|
}
|
|
|
|
if orgToken := orgTokens[corpID]; orgToken != nil {
|
|
if p := findExactProfile(cfg, corpID, orgToken.UserID); p != nil {
|
|
cfg.OrgCurrentProfiles[corpID] = ProfileSelector(*p)
|
|
changed = true
|
|
continue
|
|
}
|
|
}
|
|
for _, pointer := range []string{cfg.CurrentProfile, cfg.PreviousProfile, cfg.PrimaryProfile} {
|
|
if exact := exactProfileSelectorForCorp(cfg, corpID, pointer); exact != "" {
|
|
cfg.OrgCurrentProfiles[corpID] = exact
|
|
changed = true
|
|
break
|
|
}
|
|
}
|
|
if cfg.OrgCurrentProfiles[corpID] != "" {
|
|
continue
|
|
}
|
|
profiles := profilesForCorpID(cfg, corpID)
|
|
if len(profiles) == 1 {
|
|
cfg.OrgCurrentProfiles[corpID] = ProfileSelector(*profiles[0])
|
|
changed = true
|
|
}
|
|
}
|
|
if exact := canonicalStoredSelector(cfg, cfg.CurrentProfile); exact != "" && exact != cfg.CurrentProfile {
|
|
cfg.CurrentProfile = exact
|
|
changed = true
|
|
}
|
|
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" && exact != cfg.PrimaryProfile {
|
|
cfg.PrimaryProfile = exact
|
|
changed = true
|
|
}
|
|
if legacySelectionState && cfg.CurrentProfile == "" {
|
|
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" {
|
|
cfg.CurrentProfile = exact
|
|
changed = true
|
|
} else if len(cfg.Profiles) == 1 {
|
|
cfg.CurrentProfile = ProfileSelector(cfg.Profiles[0])
|
|
changed = true
|
|
}
|
|
}
|
|
if exact := canonicalStoredSelector(cfg, cfg.PreviousProfile); exact != "" && exact != cfg.PreviousProfile {
|
|
cfg.PreviousProfile = exact
|
|
changed = true
|
|
}
|
|
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
|
|
cfg.PreviousProfile = ""
|
|
changed = true
|
|
}
|
|
if cfg.Version < profilesVersion {
|
|
cfg.Version = profilesVersion
|
|
changed = true
|
|
}
|
|
if normalizeProfilesVersionForSelectors(cfg) {
|
|
changed = true
|
|
}
|
|
if changed {
|
|
return profilesSave(configDir, cfg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// uniqueV2GlobalRepairProfile returns the only profile that may safely be
|
|
// recovered from the legacy global token mirror. A v2 registry with multiple
|
|
// accounts in one organization is deliberately ineligible, even if one token
|
|
// happens to carry a matching userId: the global slot is a mutable compatibility
|
|
// mirror and is not authoritative account-selection state. A sole unresolved
|
|
// profile is eligible only for organization-slot repair; the token matcher below
|
|
// rejects any global token that tries to attach a userId to it.
|
|
func uniqueV2GlobalRepairProfile(cfg *ProfilesConfig, corpID string) *Profile {
|
|
profiles := profilesForCorpID(cfg, corpID)
|
|
if len(profiles) != 1 {
|
|
return nil
|
|
}
|
|
return profiles[0]
|
|
}
|
|
|
|
func legacyTokenMatchesV2RepairProfile(data *TokenData, profile *Profile) bool {
|
|
if data == nil || strings.TrimSpace(data.CorpID) != strings.TrimSpace(profile.CorpID) {
|
|
return false
|
|
}
|
|
tokenUserID := strings.TrimSpace(data.UserID)
|
|
return tokenUserID == "" || tokenUserID == strings.TrimSpace(profile.UserID)
|
|
}
|
|
|
|
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
|
|
func UpsertProfileFromToken(configDir string, data *TokenData) error {
|
|
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
|
|
}
|
|
|
|
// UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes
|
|
// the token's corp the persistent current profile.
|
|
func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error {
|
|
return withProfilesLock(configDir, func() error {
|
|
return upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent)
|
|
})
|
|
}
|
|
|
|
func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error {
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return upsertProfileFromToken(configDir, cfg, data, makeCurrent)
|
|
}
|
|
|
|
func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenData, makeCurrent bool) error {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
corpID := strings.TrimSpace(data.CorpID)
|
|
if corpID == "" {
|
|
return nil
|
|
}
|
|
if err := ensureProfilesWritable(cfg); err != nil {
|
|
return err
|
|
}
|
|
normalizeProfilesConfig(cfg)
|
|
if cfg.Version < profilesVersion {
|
|
cfg.Version = profilesVersion
|
|
}
|
|
now := time.Now().Format(time.RFC3339)
|
|
userID := strings.TrimSpace(data.UserID)
|
|
var previousCurrent *Profile
|
|
if makeCurrent && strings.TrimSpace(cfg.CurrentProfile) != "" {
|
|
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
|
}
|
|
idx := profileIndexByIdentity(cfg, corpID, userID)
|
|
if idx < 0 && userID != "" && len(profilesForCorpID(cfg, corpID)) == 1 {
|
|
// Upgrade an organization-scoped v1 profile only when it is the sole
|
|
// account in that organization. If exact identities already coexist
|
|
// with a blank profile, consuming the blank profile here would silently
|
|
// discard that unresolved historical account.
|
|
idx = legacyProfileIndexByCorpID(cfg, corpID)
|
|
}
|
|
if idx < 0 {
|
|
profile := Profile{
|
|
Name: chooseProfileName(cfg, data),
|
|
CorpID: corpID,
|
|
CorpName: strings.TrimSpace(data.CorpName),
|
|
UserID: userID,
|
|
UserName: strings.TrimSpace(data.UserName),
|
|
ClientID: strings.TrimSpace(data.ClientID),
|
|
Status: ProfileStatusActive,
|
|
LastLoginAt: now,
|
|
LastUsedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
cfg.Profiles = append(cfg.Profiles, profile)
|
|
idx = len(cfg.Profiles) - 1
|
|
} else {
|
|
p := &cfg.Profiles[idx]
|
|
if userID != "" {
|
|
p.UserID = userID
|
|
}
|
|
if shouldRefreshProfileName(p, data) {
|
|
p.Name = chooseProfileName(cfg, data)
|
|
}
|
|
if v := strings.TrimSpace(data.CorpName); v != "" {
|
|
p.CorpName = v
|
|
}
|
|
if v := strings.TrimSpace(data.UserName); v != "" {
|
|
p.UserName = v
|
|
}
|
|
if v := strings.TrimSpace(data.ClientID); v != "" {
|
|
p.ClientID = v
|
|
}
|
|
p.Status = ProfileStatusActive
|
|
p.LastLoginAt = now
|
|
p.LastUsedAt = now
|
|
p.UpdatedAt = now
|
|
}
|
|
storedProfile := &cfg.Profiles[idx]
|
|
if userID == "" && len(profilesForCorpID(cfg, corpID)) > 1 &&
|
|
(strings.TrimSpace(storedProfile.Name) == corpID || profileNameTakenByOtherIdentity(cfg, storedProfile.Name, corpID, "")) {
|
|
// A blank profile needs a stable name when exact identities coexist in
|
|
// the same organization; the corpId selector denotes the organization
|
|
// as a whole and is therefore not an exact account selector.
|
|
storedProfile.Name = chooseProfileName(cfg, data)
|
|
}
|
|
if makeCurrent {
|
|
newSelector := storedProfileSelector(cfg, storedProfile)
|
|
if previousCurrent != nil && storedProfileSelector(cfg, previousCurrent) != newSelector {
|
|
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
|
|
}
|
|
cfg.CurrentProfile = newSelector
|
|
if userID == "" {
|
|
delete(cfg.OrgCurrentProfiles, corpID)
|
|
} else {
|
|
setOrgCurrentProfile(cfg, corpID, newSelector)
|
|
}
|
|
}
|
|
if cfg.CurrentProfile == "" {
|
|
cfg.CurrentProfile = storedProfileSelector(cfg, storedProfile)
|
|
if userID == "" {
|
|
delete(cfg.OrgCurrentProfiles, corpID)
|
|
} else {
|
|
setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile)
|
|
}
|
|
}
|
|
return profilesSave(configDir, cfg)
|
|
}
|
|
|
|
// ProfileSelector returns the exact identity selector for a profile when its
|
|
// userId is known, otherwise it returns the historical corpId selector.
|
|
func ProfileSelector(profile Profile) string {
|
|
return profileSelector(profile.CorpID, profile.UserID)
|
|
}
|
|
|
|
// storedProfileSelector returns a selector that remains exact inside
|
|
// profiles.json. A blank userId has only an organization selector in the
|
|
// public compatibility surface; when other accounts share that organization,
|
|
// use the profile's unique local name so current/previous pointers do not
|
|
// accidentally resolve to an exact account through OrgCurrentProfiles.
|
|
func storedProfileSelector(cfg *ProfilesConfig, profile *Profile) string {
|
|
if profile == nil {
|
|
return ""
|
|
}
|
|
if strings.TrimSpace(profile.UserID) != "" {
|
|
return ProfileSelector(*profile)
|
|
}
|
|
if cfg == nil {
|
|
return strings.TrimSpace(profile.CorpID)
|
|
}
|
|
if len(profilesForCorpID(cfg, profile.CorpID)) <= 1 {
|
|
return strings.TrimSpace(profile.CorpID)
|
|
}
|
|
name := strings.TrimSpace(profile.Name)
|
|
if localProfileSelectorIsSafe(cfg, profile, name) {
|
|
return name
|
|
}
|
|
return unresolvedProfileSelector(profile.CorpID)
|
|
}
|
|
|
|
// ProfileSelectionSelector returns the stable selector used for one profile.
|
|
// Exact identities use corpId:userId. A historical profile without userId
|
|
// keeps the organization selector while it is the only account, and otherwise
|
|
// uses either an unambiguous local name or a reserved, reversible selector.
|
|
func ProfileSelectionSelector(profile Profile, cfg *ProfilesConfig) string {
|
|
return storedProfileSelector(cfg, &profile)
|
|
}
|
|
|
|
func localProfileSelectorIsSafe(cfg *ProfilesConfig, profile *Profile, name string) bool {
|
|
if cfg == nil || profile == nil {
|
|
return false
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || strings.Contains(name, ":") || strings.HasPrefix(name, unresolvedProfileSelectorPrefix) {
|
|
return false
|
|
}
|
|
nameMatches := 0
|
|
for i := range cfg.Profiles {
|
|
candidate := &cfg.Profiles[i]
|
|
if strings.TrimSpace(candidate.Name) == name {
|
|
nameMatches++
|
|
}
|
|
// Organization selectors are resolved before ordinary local names.
|
|
// Never persist a local selector that can be captured by that grammar.
|
|
if strings.TrimSpace(candidate.CorpID) == name || strings.TrimSpace(candidate.CorpName) == name {
|
|
return false
|
|
}
|
|
}
|
|
return nameMatches == 1
|
|
}
|
|
|
|
func unresolvedProfileSelector(corpID string) string {
|
|
corpID = strings.TrimSpace(corpID)
|
|
if corpID == "" {
|
|
return ""
|
|
}
|
|
return unresolvedProfileSelectorPrefix + base64.RawURLEncoding.EncodeToString([]byte(corpID))
|
|
}
|
|
|
|
func parseUnresolvedProfileSelector(selector string) (string, bool) {
|
|
selector = strings.TrimSpace(selector)
|
|
if !strings.HasPrefix(selector, unresolvedProfileSelectorPrefix) {
|
|
return "", false
|
|
}
|
|
encoded := strings.TrimPrefix(selector, unresolvedProfileSelectorPrefix)
|
|
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
corpID := strings.TrimSpace(string(decoded))
|
|
if corpID == "" || unresolvedProfileSelector(corpID) != selector {
|
|
return "", false
|
|
}
|
|
return corpID, true
|
|
}
|
|
|
|
// TokenProfileSelector returns the exact identity selector for token data when
|
|
// its userId is known, otherwise it returns the historical corpId selector.
|
|
func TokenProfileSelector(data *TokenData) string {
|
|
if data == nil {
|
|
return ""
|
|
}
|
|
return profileSelector(data.CorpID, data.UserID)
|
|
}
|
|
|
|
// StableTokenProfileSelector preserves the exact selector that loaded a token.
|
|
// This matters for an unresolved historical account sharing an organization
|
|
// with exact identities: reducing its selector to corpId would follow
|
|
// OrgCurrentProfiles and could mark or reauthorize a different account.
|
|
func StableTokenProfileSelector(configDir string, data *TokenData) string {
|
|
if selector := strings.TrimSpace(RuntimeProfile()); selector != "" {
|
|
return selector
|
|
}
|
|
fallback := TokenProfileSelector(data)
|
|
if data == nil {
|
|
return fallback
|
|
}
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil || cfg == nil || strings.TrimSpace(cfg.CurrentProfile) == "" {
|
|
return fallback
|
|
}
|
|
selector := canonicalStoredSelector(cfg, cfg.CurrentProfile)
|
|
if selector == "" {
|
|
selector = strings.TrimSpace(cfg.CurrentProfile)
|
|
}
|
|
profile, _, err := resolveProfileSelection(configDir, cfg, selector)
|
|
if err != nil || profile == nil ||
|
|
!sameProfileIdentity(profile.CorpID, profile.UserID, data.CorpID, data.UserID) {
|
|
return fallback
|
|
}
|
|
return storedProfileSelector(cfg, profile)
|
|
}
|
|
|
|
func profileSelector(corpID, userID string) string {
|
|
corpID = strings.TrimSpace(corpID)
|
|
userID = strings.TrimSpace(userID)
|
|
if corpID == "" {
|
|
return ""
|
|
}
|
|
if userID == "" {
|
|
return corpID
|
|
}
|
|
return corpID + ":" + userID
|
|
}
|
|
|
|
// ParseIdentitySelector splits corpId:userId selectors.
|
|
func ParseIdentitySelector(selector string) (corpID, userID string, ok bool) {
|
|
selector = strings.TrimSpace(selector)
|
|
idx := strings.Index(selector, ":")
|
|
if idx <= 0 || idx >= len(selector)-1 {
|
|
return "", "", false
|
|
}
|
|
corpID = strings.TrimSpace(selector[:idx])
|
|
userID = strings.TrimSpace(selector[idx+1:])
|
|
if corpID == "" || userID == "" {
|
|
return "", "", false
|
|
}
|
|
return corpID, userID, true
|
|
}
|
|
|
|
// ResolveProfile returns a profile selected by name/corpId/identity or by
|
|
// current fallback.
|
|
func ResolveProfile(configDir, selector string) (*Profile, error) {
|
|
p, _, err := ResolveProfileWithScope(configDir, selector)
|
|
return p, err
|
|
}
|
|
|
|
// ResolveProfileWithScope resolves a selector and reports whether it targets
|
|
// one identity (compound selector or local profile name) rather than an
|
|
// organization as a whole.
|
|
func ResolveProfileWithScope(configDir, selector string) (*Profile, bool, error) {
|
|
var (
|
|
result *Profile
|
|
exact bool
|
|
)
|
|
err := withProfilesLock(configDir, func() error {
|
|
var resolveErr error
|
|
result, exact, resolveErr = resolveProfileWithScopeLocked(configDir, selector)
|
|
return resolveErr
|
|
})
|
|
return result, exact, err
|
|
}
|
|
|
|
// ResolveProfileMetadata applies the public profile-selector grammar to an
|
|
// already-loaded, non-sensitive profiles registry. It performs no migration,
|
|
// keychain access, token loading, or persistence, making it suitable for
|
|
// callers that carry an externally managed bearer credential and need only
|
|
// corp/user/client identity metadata.
|
|
func ResolveProfileMetadata(cfg *ProfilesConfig, selector string) (*Profile, error) {
|
|
profile, _, err := resolveProfileSelection("", cfg, selector)
|
|
if err != nil || profile == nil {
|
|
return nil, err
|
|
}
|
|
copy := *profile
|
|
return ©, nil
|
|
}
|
|
|
|
func resolveProfileWithScopeLocked(configDir, selector string) (*Profile, bool, error) {
|
|
if err := profilesEnsureMigration(configDir); err != nil {
|
|
return nil, false, err
|
|
}
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if selector != "" {
|
|
return resolveProfileSelection(configDir, cfg, selector)
|
|
}
|
|
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
|
return resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
|
|
// ResolveProfileDeletionScope resolves selectors for destructive profile
|
|
// removal. Organization selectors intentionally resolve to the whole
|
|
// organization even when it has multiple accounts and no current account.
|
|
func ResolveProfileDeletionScope(configDir, selector string) (*Profile, bool, error) {
|
|
var (
|
|
result *Profile
|
|
exact bool
|
|
)
|
|
err := withProfilesLock(configDir, func() error {
|
|
if err := profilesEnsureMigration(configDir); err != nil {
|
|
return err
|
|
}
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ensureProfilesWritable(cfg); err != nil {
|
|
return err
|
|
}
|
|
var resolveErr error
|
|
result, exact, resolveErr = resolveProfileDeletionSelection(cfg, selector)
|
|
return resolveErr
|
|
})
|
|
return result, exact, err
|
|
}
|
|
|
|
func resolveProfileForLoadLocked(configDir, selector string) (*Profile, *ProfilesConfig, error) {
|
|
if err := profilesEnsureMigration(configDir); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if selector != "" {
|
|
p, _, resolveErr := resolveProfileSelection(configDir, cfg, selector)
|
|
return p, cfg, resolveErr
|
|
}
|
|
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
|
p, _, resolveErr := resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
|
return p, cfg, resolveErr
|
|
}
|
|
return nil, cfg, nil
|
|
}
|
|
|
|
// resolveProfileForLoad is retained as a focused test seam around the locked
|
|
// resolver. Production callers already hold the auth lock.
|
|
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
|
|
profile, _, err := resolveProfileForLoadLocked(configDir, selector)
|
|
return profile, err
|
|
}
|
|
|
|
// SetCurrentProfile persists the selected current profile.
|
|
func SetCurrentProfile(configDir, selector string) (*Profile, error) {
|
|
var result *Profile
|
|
err := withProfilesLock(configDir, func() error {
|
|
p, e := setCurrentProfileLocked(configDir, selector)
|
|
result = p
|
|
return e
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
|
|
if err := profilesEnsureMigration(configDir); err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureProfilesWritable(cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
p, _, err := resolveProfileSelection(configDir, cfg, selector)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
originalCfg := cloneProfilesConfig(cfg)
|
|
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
|
|
if !syncOrganization {
|
|
if err := validateIdentityOnlyProfileToken(*p); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var previousCurrent *Profile
|
|
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
|
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
|
}
|
|
storedSelector := storedProfileSelector(cfg, p)
|
|
if cfg.CurrentProfile != storedSelector {
|
|
if previousCurrent != nil {
|
|
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
|
|
}
|
|
cfg.CurrentProfile = storedSelector
|
|
}
|
|
if strings.TrimSpace(p.UserID) == "" {
|
|
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID))
|
|
} else {
|
|
setOrgCurrentProfile(cfg, p.CorpID, storedSelector)
|
|
}
|
|
touchProfileUsage(p)
|
|
if err := profilesSave(configDir, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
if syncOrganization {
|
|
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
|
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
|
}
|
|
}
|
|
if err := profilesSyncLegacyMirror(configDir); err != nil {
|
|
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
|
}
|
|
return findExactProfile(cfg, p.CorpID, p.UserID), nil
|
|
}
|
|
|
|
// UsePreviousProfile toggles currentProfile and previousProfile.
|
|
func UsePreviousProfile(configDir string) (*Profile, error) {
|
|
var result *Profile
|
|
err := withProfilesLock(configDir, func() error {
|
|
p, e := usePreviousProfileLocked(configDir)
|
|
result = p
|
|
return e
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func usePreviousProfileLocked(configDir string) (*Profile, error) {
|
|
if err := profilesEnsureMigration(configDir); err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureProfilesWritable(cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
prev := strings.TrimSpace(cfg.PreviousProfile)
|
|
if prev == "" {
|
|
return nil, fmt.Errorf("previous profile is empty")
|
|
}
|
|
p, _, err := resolveProfileSelection(configDir, cfg, prev)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve previous profile %q: %w", prev, err)
|
|
}
|
|
originalCfg := cloneProfilesConfig(cfg)
|
|
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
|
|
if !syncOrganization {
|
|
if err := validateIdentityOnlyProfileToken(*p); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var current *Profile
|
|
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
|
current, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
|
}
|
|
cfg.CurrentProfile = storedProfileSelector(cfg, p)
|
|
if current != nil {
|
|
cfg.PreviousProfile = storedProfileSelector(cfg, current)
|
|
} else {
|
|
cfg.PreviousProfile = ""
|
|
}
|
|
if strings.TrimSpace(p.UserID) == "" {
|
|
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID))
|
|
} else {
|
|
setOrgCurrentProfile(cfg, p.CorpID, ProfileSelector(*p))
|
|
}
|
|
touchProfileUsage(p)
|
|
if err := profilesSave(configDir, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
if syncOrganization {
|
|
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
|
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
|
}
|
|
}
|
|
if err := profilesSyncLegacyMirror(configDir); err != nil {
|
|
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
|
}
|
|
return findExactProfile(cfg, p.CorpID, p.UserID), nil
|
|
}
|
|
|
|
func touchProfileUsage(profile *Profile) {
|
|
if profile == nil {
|
|
return
|
|
}
|
|
now := time.Now().Format(time.RFC3339)
|
|
profile.LastUsedAt = now
|
|
profile.UpdatedAt = now
|
|
}
|
|
|
|
// RemoveProfile removes a profile from metadata and returns the removed profile.
|
|
func RemoveProfile(configDir, selector string) (*Profile, error) {
|
|
var result *Profile
|
|
err := withProfilesLock(configDir, func() error {
|
|
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
|
return err
|
|
}
|
|
p, e := removeProfileLocked(configDir, selector)
|
|
result = p
|
|
return e
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureProfilesWritable(cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
p, exact, err := resolveProfileDeletionSelection(cfg, selector)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
removed := *p
|
|
originalCurrentSelector := strings.TrimSpace(cfg.CurrentProfile)
|
|
originalOrganizationCurrent := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)])
|
|
pointers := []*string{&cfg.PrimaryProfile, &cfg.CurrentProfile, &cfg.PreviousProfile}
|
|
pointerMatches := make([]bool, len(pointers))
|
|
for i, pointer := range pointers {
|
|
selected, _, resolveErr := resolveProfileSelection(configDir, cfg, *pointer)
|
|
if resolveErr == nil && selected != nil {
|
|
if exact {
|
|
pointerMatches[i] =
|
|
sameProfileIdentity(selected.CorpID, selected.UserID, removed.CorpID, removed.UserID)
|
|
} else {
|
|
pointerMatches[i] = strings.TrimSpace(selected.CorpID) == strings.TrimSpace(removed.CorpID)
|
|
}
|
|
}
|
|
}
|
|
kept := cfg.Profiles[:0]
|
|
for _, profile := range cfg.Profiles {
|
|
remove := profile.CorpID == removed.CorpID
|
|
if exact {
|
|
remove = sameProfileIdentity(profile.CorpID, profile.UserID, removed.CorpID, removed.UserID)
|
|
}
|
|
if !remove {
|
|
kept = append(kept, profile)
|
|
}
|
|
}
|
|
cfg.Profiles = kept
|
|
replacementSelector := ""
|
|
if exact && len(profilesForCorpID(cfg, removed.CorpID)) > 0 {
|
|
remaining := profilesForCorpID(cfg, removed.CorpID)
|
|
if len(remaining) == 1 {
|
|
replacementSelector = storedProfileSelector(cfg, remaining[0])
|
|
}
|
|
}
|
|
if exact {
|
|
if selectorMatchesIdentity(cfg.OrgCurrentProfiles[removed.CorpID], removed) {
|
|
if replacementSelector == "" {
|
|
delete(cfg.OrgCurrentProfiles, removed.CorpID)
|
|
} else {
|
|
cfg.OrgCurrentProfiles[removed.CorpID] = replacementSelector
|
|
}
|
|
}
|
|
} else {
|
|
delete(cfg.OrgCurrentProfiles, removed.CorpID)
|
|
}
|
|
for i, pointer := range pointers {
|
|
if exact {
|
|
if pointerMatches[i] {
|
|
*pointer = replacementSelector
|
|
}
|
|
continue
|
|
}
|
|
if pointerMatches[i] || selectorTargetsCorp(*pointer, removed.CorpID) {
|
|
*pointer = ""
|
|
}
|
|
}
|
|
if cfg.CurrentProfile == "" {
|
|
if previous := canonicalStoredSelector(cfg, cfg.PreviousProfile); previous != "" {
|
|
cfg.CurrentProfile = previous
|
|
cfg.PreviousProfile = ""
|
|
} else if len(cfg.Profiles) == 1 {
|
|
cfg.CurrentProfile = storedProfileSelector(cfg, &cfg.Profiles[0])
|
|
}
|
|
}
|
|
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
|
|
cfg.PreviousProfile = ""
|
|
}
|
|
currentSelectionChanged := strings.TrimSpace(cfg.CurrentProfile) != originalCurrentSelector
|
|
organizationCurrentChanged := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)]) != originalOrganizationCurrent
|
|
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
|
if current, _, resolveErr := resolveProfileSelection(configDir, cfg, cfg.CurrentProfile); resolveErr == nil && current != nil {
|
|
if (currentSelectionChanged || organizationCurrentChanged) &&
|
|
strings.TrimSpace(current.CorpID) == strings.TrimSpace(removed.CorpID) &&
|
|
unresolvedProfileForCorp(cfg, removed.CorpID) != nil {
|
|
if strings.TrimSpace(current.UserID) == "" {
|
|
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(current.CorpID))
|
|
} else {
|
|
setOrgCurrentProfile(cfg, current.CorpID, storedProfileSelector(cfg, current))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Removing the exact identity that forced a blank sibling to use the v3
|
|
// reserved selector can make that blank profile the sole account in its
|
|
// organization. Re-canonicalize every surviving pointer against the final
|
|
// profile set before SaveProfiles derives the schema version, so the pointer
|
|
// collapses back to the v2 corpId grammar instead of pinning the file at v3.
|
|
for _, pointer := range pointers {
|
|
if canonical := canonicalStoredSelector(cfg, *pointer); canonical != "" {
|
|
*pointer = canonical
|
|
}
|
|
}
|
|
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
|
|
cfg.PreviousProfile = ""
|
|
}
|
|
if len(cfg.Profiles) == 0 {
|
|
cfg.PrimaryProfile = ""
|
|
cfg.CurrentProfile = ""
|
|
cfg.PreviousProfile = ""
|
|
cfg.OrgCurrentProfiles = nil
|
|
}
|
|
if err := profilesSave(configDir, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &removed, nil
|
|
}
|
|
|
|
func selectorMatchesIdentity(selector string, profile Profile) bool {
|
|
corpID, userID, exact := ParseIdentitySelector(selector)
|
|
return exact && sameProfileIdentity(corpID, userID, profile.CorpID, profile.UserID)
|
|
}
|
|
|
|
func selectorTargetsCorp(selector, corpID string) bool {
|
|
selector = strings.TrimSpace(selector)
|
|
corpID = strings.TrimSpace(corpID)
|
|
if selector == corpID {
|
|
return true
|
|
}
|
|
if selectedCorpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
|
return selectedCorpID == corpID
|
|
}
|
|
selectedCorpID, _, exact := ParseIdentitySelector(selector)
|
|
return exact && selectedCorpID == corpID
|
|
}
|
|
|
|
// MarkProfileStatus updates a selected profile status if it exists.
|
|
func MarkProfileStatus(configDir, selector, status string) error {
|
|
if strings.TrimSpace(selector) == "" {
|
|
return nil
|
|
}
|
|
return withProfilesLock(configDir, func() error {
|
|
return markProfileStatusLocked(configDir, selector, status)
|
|
})
|
|
}
|
|
|
|
func markProfileStatusLocked(configDir, selector, status string) error {
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ensureProfilesWritable(cfg); err != nil {
|
|
return err
|
|
}
|
|
p, _, resolveErr := resolveProfileSelection(configDir, cfg, selector)
|
|
if resolveErr != nil {
|
|
return nil
|
|
}
|
|
p.Status = strings.TrimSpace(status)
|
|
p.UpdatedAt = time.Now().Format(time.RFC3339)
|
|
return profilesSave(configDir, cfg)
|
|
}
|
|
|
|
func ensureProfilesWritable(cfg *ProfilesConfig) error {
|
|
if cfg != nil && cfg.Version > profilesMaxVersion {
|
|
return fmt.Errorf(
|
|
"profiles.json version %d is newer than supported version %d; upgrade dws before changing profiles",
|
|
cfg.Version,
|
|
profilesMaxVersion,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeProfilesVersionForSelectors derives the persisted schema version
|
|
// from the final normalized selector grammar. Keep v3 only while a legal
|
|
// reserved unresolved-identity selector remains on disk; once completion,
|
|
// deletion, or canonicalization removes that grammar, the file is again safe
|
|
// for v2 clients and should downgrade to v2.
|
|
func normalizeProfilesVersionForSelectors(cfg *ProfilesConfig) bool {
|
|
if cfg == nil || cfg.Version > profilesMaxVersion {
|
|
return false
|
|
}
|
|
|
|
target := cfg.Version
|
|
if profilesConfigContainsUnresolvedSelector(cfg) {
|
|
target = profilesUnresolvedSelectorVersion
|
|
} else if cfg.Version >= profilesVersion {
|
|
target = profilesVersion
|
|
}
|
|
if target == cfg.Version {
|
|
return false
|
|
}
|
|
cfg.Version = target
|
|
return true
|
|
}
|
|
|
|
func profilesConfigContainsUnresolvedSelector(cfg *ProfilesConfig) bool {
|
|
if cfg == nil {
|
|
return false
|
|
}
|
|
for _, selector := range []string{cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile} {
|
|
if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
|
return true
|
|
}
|
|
}
|
|
for _, selector := range cfg.OrgCurrentProfiles {
|
|
if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type profileSelectionMirrorSnapshot struct {
|
|
organization tokenSlotSnapshot
|
|
legacy tokenSlotSnapshot
|
|
marker tokenMarkerSnapshot
|
|
}
|
|
|
|
func snapshotProfileSelectionMirrors(configDir, corpID string, includeOrganization bool) (profileSelectionMirrorSnapshot, error) {
|
|
var organization tokenSlotSnapshot
|
|
if includeOrganization {
|
|
var err error
|
|
organization, err = snapshotTokenSlot(func() (*TokenData, error) {
|
|
return profilesLoadCorp(corpID)
|
|
})
|
|
if err != nil {
|
|
return profileSelectionMirrorSnapshot{}, err
|
|
}
|
|
}
|
|
legacy, err := snapshotTokenSlot(profilesLoadLegacy)
|
|
if err != nil {
|
|
return profileSelectionMirrorSnapshot{}, err
|
|
}
|
|
marker, err := snapshotTokenMarker(configDir)
|
|
if err != nil {
|
|
return profileSelectionMirrorSnapshot{}, err
|
|
}
|
|
return profileSelectionMirrorSnapshot{
|
|
organization: organization,
|
|
legacy: legacy,
|
|
marker: marker,
|
|
}, nil
|
|
}
|
|
|
|
func rollbackProfileSelection(
|
|
configDir string,
|
|
cfg *ProfilesConfig,
|
|
corpID string,
|
|
mirrors profileSelectionMirrorSnapshot,
|
|
operationErr error,
|
|
) error {
|
|
var rollbackErr error
|
|
if err := profilesSave(configDir, cloneProfilesConfig(cfg)); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
if mirrors.organization.known {
|
|
if mirrors.organization.exists {
|
|
if err := profilesSaveCorp(corpID, mirrors.organization.token); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
} else if err := profilesDeleteCorp(corpID); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
}
|
|
if mirrors.legacy.exists {
|
|
if err := profilesSaveLegacy(mirrors.legacy.token); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
} else if err := profilesDeleteLegacy(); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
switch {
|
|
case !mirrors.marker.exists:
|
|
if err := profilesDeleteMarker(configDir); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
case mirrors.marker.manual:
|
|
if err := profilesWriteManualMarker(configDir); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
default:
|
|
if err := profilesWriteMarker(configDir); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
}
|
|
if rollbackErr != nil {
|
|
return errors.Join(operationErr, fmt.Errorf("rollback profile selection: %w", rollbackErr))
|
|
}
|
|
return operationErr
|
|
}
|
|
|
|
// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token.
|
|
func SyncLegacyTokenMirror(configDir string) error {
|
|
return withProfilesLock(configDir, func() error {
|
|
return syncLegacyTokenMirrorLocked(configDir)
|
|
})
|
|
}
|
|
|
|
func syncLegacyTokenMirrorLocked(configDir string) error {
|
|
cfg, err := profilesLoad(configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
current := strings.TrimSpace(cfg.CurrentProfile)
|
|
if current != "" {
|
|
p, _, resolveErr := resolveProfileSelection(configDir, cfg, current)
|
|
if resolveErr != nil {
|
|
return resolveErr
|
|
}
|
|
data, loadErr := loadTokenForProfileIdentity(*p)
|
|
if loadErr != nil {
|
|
if errors.Is(loadErr, ErrTokenDataNotFound) {
|
|
// The current identity and its organization mirror are both
|
|
// confirmed absent. Clear the stale global compatibility mirror.
|
|
} else {
|
|
// Keep the existing legacy mirror untouched rather than wiping a host
|
|
// app's login state just because keychain was momentarily unavailable.
|
|
return nil
|
|
}
|
|
}
|
|
if data != nil {
|
|
if err := profilesSaveLegacy(data); err != nil {
|
|
return err
|
|
}
|
|
return profilesWriteMarker(configDir)
|
|
}
|
|
}
|
|
// No current profile (or its token is confirmed absent): clear the mirror.
|
|
if err := profilesDeleteLegacy(); err != nil {
|
|
return err
|
|
}
|
|
return profilesDeleteMarker(configDir)
|
|
}
|
|
|
|
func syncOrganizationTokenMirrorForProfile(profile Profile) error {
|
|
data, err := loadTokenForProfileIdentity(profile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return profilesSaveCorp(profile.CorpID, data)
|
|
}
|
|
|
|
func loadTokenForProfileIdentity(profile Profile) (*TokenData, error) {
|
|
if strings.TrimSpace(profile.UserID) == "" {
|
|
data, err := profilesLoadCorp(profile.CorpID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if data == nil {
|
|
return nil, ErrTokenDataNotFound
|
|
}
|
|
if strings.TrimSpace(data.UserID) != "" {
|
|
return nil, fmt.Errorf(
|
|
"organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q",
|
|
profile.CorpID,
|
|
data.UserID,
|
|
profile.Name,
|
|
)
|
|
}
|
|
return data, nil
|
|
}
|
|
data, err := profilesLoadIdentity(profile.CorpID, profile.UserID)
|
|
if err == nil {
|
|
return data, nil
|
|
}
|
|
if !errors.Is(err, ErrTokenDataNotFound) {
|
|
return nil, err
|
|
}
|
|
orgData, orgErr := profilesLoadCorp(profile.CorpID)
|
|
if orgErr != nil {
|
|
if errors.Is(orgErr, ErrTokenDataNotFound) {
|
|
return nil, err
|
|
}
|
|
return nil, orgErr
|
|
}
|
|
if strings.TrimSpace(orgData.UserID) == "" {
|
|
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile))
|
|
}
|
|
if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) {
|
|
return nil, err
|
|
}
|
|
if saveErr := profilesSaveIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
|
|
return nil, saveErr
|
|
}
|
|
return orgData, nil
|
|
}
|
|
|
|
func normalizeProfilesConfig(cfg *ProfilesConfig) {
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
if cfg.Version <= 0 {
|
|
cfg.Version = 1
|
|
}
|
|
seen := make(map[string]bool, len(cfg.Profiles))
|
|
profiles := cfg.Profiles[:0]
|
|
for _, p := range cfg.Profiles {
|
|
p.CorpID = strings.TrimSpace(p.CorpID)
|
|
p.UserID = strings.TrimSpace(p.UserID)
|
|
identity := profileIdentityKey(p.CorpID, p.UserID)
|
|
if p.CorpID == "" || seen[identity] {
|
|
continue
|
|
}
|
|
seen[identity] = true
|
|
p.Name = strings.TrimSpace(p.Name)
|
|
if p.Name == "" {
|
|
p.Name = p.CorpID
|
|
}
|
|
if corpName := strings.TrimSpace(p.CorpName); p.Name == p.CorpID && corpName != "" && !profileNameTakenByOtherIdentity(cfg, corpName, p.CorpID, p.UserID) {
|
|
p.Name = corpName
|
|
}
|
|
if p.Status == "" {
|
|
p.Status = ProfileStatusActive
|
|
}
|
|
profiles = append(profiles, p)
|
|
}
|
|
cfg.Profiles = profiles
|
|
if len(cfg.OrgCurrentProfiles) > 0 {
|
|
normalized := make(map[string]string, len(cfg.OrgCurrentProfiles))
|
|
for corpID, selector := range cfg.OrgCurrentProfiles {
|
|
corpID = strings.TrimSpace(corpID)
|
|
if exact := exactProfileSelectorForCorp(cfg, corpID, selector); exact != "" {
|
|
normalized[corpID] = exact
|
|
}
|
|
}
|
|
if len(normalized) == 0 {
|
|
cfg.OrgCurrentProfiles = nil
|
|
} else {
|
|
cfg.OrgCurrentProfiles = normalized
|
|
}
|
|
}
|
|
if cfg.PrimaryProfile != "" && !profileSelectorReferenceExists(cfg, cfg.PrimaryProfile) {
|
|
cfg.PrimaryProfile = ""
|
|
}
|
|
if cfg.CurrentProfile != "" && !profileSelectorReferenceExists(cfg, cfg.CurrentProfile) {
|
|
cfg.CurrentProfile = ""
|
|
}
|
|
if cfg.PreviousProfile != "" && !profileSelectorReferenceExists(cfg, cfg.PreviousProfile) {
|
|
cfg.PreviousProfile = ""
|
|
}
|
|
}
|
|
|
|
func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string {
|
|
base := strings.TrimSpace(data.CorpName)
|
|
if base == "" {
|
|
base = strings.TrimSpace(data.CorpID)
|
|
}
|
|
if base == "" {
|
|
base = "profile"
|
|
}
|
|
corpID := strings.TrimSpace(data.CorpID)
|
|
userID := strings.TrimSpace(data.UserID)
|
|
if !profileNameTakenByOtherIdentity(cfg, base, corpID, userID) {
|
|
return base
|
|
}
|
|
for _, suffix := range []string{
|
|
strings.TrimSpace(data.UserName),
|
|
shortProfileID(userID),
|
|
shortCorpID(corpID),
|
|
} {
|
|
if suffix == "" {
|
|
continue
|
|
}
|
|
name := base + "-" + suffix
|
|
if !profileNameTakenByOtherIdentity(cfg, name, corpID, userID) {
|
|
return name
|
|
}
|
|
}
|
|
for i := 2; ; i++ {
|
|
candidate := fmt.Sprintf("%s-%d", base, i)
|
|
if !profileNameTakenByOtherIdentity(cfg, candidate, corpID, userID) {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
func shouldRefreshProfileName(p *Profile, data *TokenData) bool {
|
|
if p == nil || data == nil {
|
|
return false
|
|
}
|
|
name := strings.TrimSpace(p.Name)
|
|
if name == "" {
|
|
return true
|
|
}
|
|
return strings.TrimSpace(data.CorpName) != "" && name == strings.TrimSpace(p.CorpID)
|
|
}
|
|
|
|
func profileNameTakenByOtherIdentity(cfg *ProfilesConfig, name, corpID, userID string) bool {
|
|
name = strings.TrimSpace(name)
|
|
corpID = strings.TrimSpace(corpID)
|
|
userID = strings.TrimSpace(userID)
|
|
for _, p := range cfg.Profiles {
|
|
if strings.TrimSpace(p.Name) == name && !sameProfileIdentity(p.CorpID, p.UserID, corpID, userID) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveProfileSelection resolves a user-facing selector to one exact identity.
|
|
// The bool reports whether the selector targets one identity (compound selector
|
|
// or local profile name) rather than an organization as a whole.
|
|
func resolveProfileSelection(_ string, cfg *ProfilesConfig, selector string) (*Profile, bool, error) {
|
|
if cfg == nil {
|
|
return nil, false, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if selector == "" {
|
|
return nil, false, fmt.Errorf("profile selector is empty")
|
|
}
|
|
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
|
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
|
|
return profile, true, nil
|
|
}
|
|
return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID)
|
|
}
|
|
|
|
if organization, account, compound := ParseIdentitySelector(selector); compound {
|
|
corpID, err := resolveOrganizationCorpID(cfg, organization)
|
|
if err != nil {
|
|
return nil, true, err
|
|
}
|
|
if corpID == "" {
|
|
return nil, true, fmt.Errorf("organization %q not found", organization)
|
|
}
|
|
if p := findExactProfile(cfg, corpID, account); p != nil {
|
|
return p, true, nil
|
|
}
|
|
var matches []*Profile
|
|
for _, p := range profilesForCorpID(cfg, corpID) {
|
|
if strings.TrimSpace(p.UserName) == account {
|
|
matches = append(matches, p)
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 1:
|
|
return matches[0], true, nil
|
|
case 0:
|
|
return nil, true, fmt.Errorf("account %q not found in organization %q", account, organization)
|
|
default:
|
|
return nil, true, fmt.Errorf(
|
|
"account name %q is ambiguous in organization %q; use one of: %s",
|
|
account,
|
|
organization,
|
|
strings.Join(profileSelectorCandidates(matches), ", "),
|
|
)
|
|
}
|
|
}
|
|
|
|
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
|
|
return resolveOrganizationDefault(cfg, selector, selector, profiles)
|
|
}
|
|
|
|
orgIDs := make(map[string]struct{})
|
|
for i := range cfg.Profiles {
|
|
if strings.TrimSpace(cfg.Profiles[i].CorpName) != selector {
|
|
continue
|
|
}
|
|
orgIDs[strings.TrimSpace(cfg.Profiles[i].CorpID)] = struct{}{}
|
|
}
|
|
if len(orgIDs) == 1 {
|
|
for corpID := range orgIDs {
|
|
return resolveOrganizationDefault(cfg, corpID, selector, profilesForCorpID(cfg, corpID))
|
|
}
|
|
}
|
|
if len(orgIDs) > 1 {
|
|
candidates := make([]string, 0, len(orgIDs))
|
|
for corpID := range orgIDs {
|
|
candidates = append(candidates, corpID)
|
|
}
|
|
sort.Strings(candidates)
|
|
return nil, false, fmt.Errorf(
|
|
"organization name %q is ambiguous; use one of: %s",
|
|
selector,
|
|
strings.Join(candidates, ", "),
|
|
)
|
|
}
|
|
|
|
var nameMatches []*Profile
|
|
for i := range cfg.Profiles {
|
|
if strings.TrimSpace(cfg.Profiles[i].Name) != selector {
|
|
continue
|
|
}
|
|
nameMatches = append(nameMatches, &cfg.Profiles[i])
|
|
}
|
|
switch len(nameMatches) {
|
|
case 1:
|
|
return nameMatches[0], true, nil
|
|
case 0:
|
|
return nil, false, fmt.Errorf("profile %q not found", selector)
|
|
default:
|
|
return nil, true, fmt.Errorf(
|
|
"profile name %q is ambiguous; use one of: %s",
|
|
selector,
|
|
strings.Join(profileSelectorCandidates(nameMatches), ", "),
|
|
)
|
|
}
|
|
}
|
|
|
|
func resolveProfileDeletionSelection(cfg *ProfilesConfig, selector string) (*Profile, bool, error) {
|
|
if cfg == nil {
|
|
return nil, false, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if selector == "" {
|
|
return nil, false, fmt.Errorf("profile selector is empty")
|
|
}
|
|
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
|
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
|
|
return profile, true, nil
|
|
}
|
|
return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID)
|
|
}
|
|
if _, _, compound := ParseIdentitySelector(selector); compound {
|
|
return resolveProfileSelection("", cfg, selector)
|
|
}
|
|
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
|
|
return profiles[0], false, nil
|
|
}
|
|
corpID, err := resolveOrganizationCorpID(cfg, selector)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if corpID != "" {
|
|
profiles := profilesForCorpID(cfg, corpID)
|
|
if len(profiles) > 0 {
|
|
return profiles[0], false, nil
|
|
}
|
|
}
|
|
|
|
var nameMatches []*Profile
|
|
for i := range cfg.Profiles {
|
|
if strings.TrimSpace(cfg.Profiles[i].Name) == selector {
|
|
nameMatches = append(nameMatches, &cfg.Profiles[i])
|
|
}
|
|
}
|
|
switch len(nameMatches) {
|
|
case 1:
|
|
return nameMatches[0], true, nil
|
|
case 0:
|
|
return nil, false, fmt.Errorf("profile %q not found", selector)
|
|
default:
|
|
return nil, true, fmt.Errorf(
|
|
"profile name %q is ambiguous; use one of: %s",
|
|
selector,
|
|
strings.Join(profileSelectorCandidates(nameMatches), ", "),
|
|
)
|
|
}
|
|
}
|
|
|
|
func resolveOrganizationCorpID(cfg *ProfilesConfig, selector string) (string, error) {
|
|
if cfg == nil {
|
|
return "", nil
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if selector == "" {
|
|
return "", nil
|
|
}
|
|
if len(profilesForCorpID(cfg, selector)) > 0 {
|
|
return selector, nil
|
|
}
|
|
orgIDs := make(map[string]struct{})
|
|
for i := range cfg.Profiles {
|
|
if strings.TrimSpace(cfg.Profiles[i].CorpName) != selector {
|
|
continue
|
|
}
|
|
orgIDs[strings.TrimSpace(cfg.Profiles[i].CorpID)] = struct{}{}
|
|
}
|
|
if len(orgIDs) == 0 {
|
|
return "", nil
|
|
}
|
|
if len(orgIDs) == 1 {
|
|
for corpID := range orgIDs {
|
|
return corpID, nil
|
|
}
|
|
}
|
|
candidates := make([]string, 0, len(orgIDs))
|
|
for corpID := range orgIDs {
|
|
candidates = append(candidates, corpID)
|
|
}
|
|
sort.Strings(candidates)
|
|
return "", fmt.Errorf(
|
|
"organization name %q is ambiguous; use one of: %s",
|
|
selector,
|
|
strings.Join(candidates, ", "),
|
|
)
|
|
}
|
|
|
|
func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector string, profiles []*Profile) (*Profile, bool, error) {
|
|
if len(profiles) == 0 {
|
|
return nil, false, fmt.Errorf("organization %q not found", displaySelector)
|
|
}
|
|
if exact := exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]); exact != "" {
|
|
selectedCorpID, userID, _ := ParseIdentitySelector(exact)
|
|
if p := findExactProfile(cfg, selectedCorpID, userID); p != nil {
|
|
return p, false, nil
|
|
}
|
|
}
|
|
if unresolved := unresolvedProfileForCorp(cfg, corpID); unresolved != nil {
|
|
// With no exact organization-current selection, the organization slot
|
|
// belongs to the sole unresolved historical account. Do not choose an
|
|
// arbitrary exact identity merely because it shares the corpId.
|
|
return unresolved, false, nil
|
|
}
|
|
if len(profiles) == 1 {
|
|
return profiles[0], false, nil
|
|
}
|
|
return nil, false, fmt.Errorf(
|
|
"organization %q has multiple accounts and no current account; use one of: %s",
|
|
displaySelector,
|
|
strings.Join(profileSelectorCandidates(profiles), ", "),
|
|
)
|
|
}
|
|
|
|
func profileSelectorCandidates(profiles []*Profile) []string {
|
|
cfg := &ProfilesConfig{Profiles: make([]Profile, 0, len(profiles))}
|
|
for _, profile := range profiles {
|
|
if profile != nil {
|
|
cfg.Profiles = append(cfg.Profiles, *profile)
|
|
}
|
|
}
|
|
candidates := make([]string, 0, len(profiles))
|
|
for _, p := range profiles {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
candidates = append(candidates, storedProfileSelector(cfg, p))
|
|
}
|
|
sort.Strings(candidates)
|
|
return candidates
|
|
}
|
|
|
|
func exactProfileSelectorForCorp(cfg *ProfilesConfig, corpID, selector string) string {
|
|
selectedCorpID, userID, exact := ParseIdentitySelector(selector)
|
|
if !exact || strings.TrimSpace(selectedCorpID) != strings.TrimSpace(corpID) {
|
|
return ""
|
|
}
|
|
if p := findExactProfile(cfg, selectedCorpID, userID); p != nil {
|
|
return ProfileSelector(*p)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string {
|
|
selector = strings.TrimSpace(selector)
|
|
if selector == "" {
|
|
return ""
|
|
}
|
|
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
|
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
|
|
return storedProfileSelector(cfg, profile)
|
|
}
|
|
return ""
|
|
}
|
|
if corpID, userID, exact := ParseIdentitySelector(selector); exact {
|
|
if p := findExactProfile(cfg, corpID, userID); p != nil {
|
|
return ProfileSelector(*p)
|
|
}
|
|
// A colon-containing legacy local name is recoverable only when it does
|
|
// not name a real exact identity. Exact corpId:userId always wins.
|
|
if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil {
|
|
return storedProfileSelector(cfg, profile)
|
|
}
|
|
return ""
|
|
}
|
|
// Older multi-account writers stored the unresolved profile's local name.
|
|
// Recover it only when no profile gives the same text organization-selector
|
|
// meaning. CorpId and CorpName have always outranked local names in the
|
|
// public resolver; migration must preserve that precedence instead of
|
|
// silently redirecting one organization's selector to another blank profile.
|
|
if !selectorConflictsWithOrganizationGrammar(cfg, selector) {
|
|
if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil {
|
|
return storedProfileSelector(cfg, profile)
|
|
}
|
|
}
|
|
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
|
|
if exact := exactProfileSelectorForCorp(cfg, selector, cfg.OrgCurrentProfiles[selector]); exact != "" {
|
|
return exact
|
|
}
|
|
if len(profiles) == 1 {
|
|
return storedProfileSelector(cfg, profiles[0])
|
|
}
|
|
return ""
|
|
}
|
|
p, _, err := resolveProfileSelection("", cfg, selector)
|
|
if err != nil || p == nil {
|
|
return ""
|
|
}
|
|
return storedProfileSelector(cfg, p)
|
|
}
|
|
|
|
func selectorConflictsWithOrganizationGrammar(cfg *ProfilesConfig, selector string) bool {
|
|
if cfg == nil {
|
|
return false
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
for i := range cfg.Profiles {
|
|
if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector ||
|
|
strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func setOrgCurrentProfile(cfg *ProfilesConfig, corpID, selector string) {
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
exact := exactProfileSelectorForCorp(cfg, corpID, selector)
|
|
if exact == "" {
|
|
return
|
|
}
|
|
if cfg.OrgCurrentProfiles == nil {
|
|
cfg.OrgCurrentProfiles = make(map[string]string)
|
|
}
|
|
cfg.OrgCurrentProfiles[strings.TrimSpace(corpID)] = exact
|
|
}
|
|
|
|
func uniqueProfileCorpIDs(cfg *ProfilesConfig) []string {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
seen := make(map[string]bool)
|
|
result := make([]string, 0)
|
|
for _, p := range cfg.Profiles {
|
|
corpID := strings.TrimSpace(p.CorpID)
|
|
if corpID == "" || seen[corpID] {
|
|
continue
|
|
}
|
|
seen[corpID] = true
|
|
result = append(result, corpID)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func profileIndexByIdentity(cfg *ProfilesConfig, corpID, userID string) int {
|
|
if cfg == nil {
|
|
return -1
|
|
}
|
|
for i := range cfg.Profiles {
|
|
if sameProfileIdentity(cfg.Profiles[i].CorpID, cfg.Profiles[i].UserID, corpID, userID) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func legacyProfileIndexByCorpID(cfg *ProfilesConfig, corpID string) int {
|
|
if cfg == nil {
|
|
return -1
|
|
}
|
|
match := -1
|
|
for i := range cfg.Profiles {
|
|
if strings.TrimSpace(cfg.Profiles[i].CorpID) != strings.TrimSpace(corpID) || strings.TrimSpace(cfg.Profiles[i].UserID) != "" {
|
|
continue
|
|
}
|
|
if match >= 0 {
|
|
return -1
|
|
}
|
|
match = i
|
|
}
|
|
return match
|
|
}
|
|
|
|
func findExactProfile(cfg *ProfilesConfig, corpID, userID string) *Profile {
|
|
idx := profileIndexByIdentity(cfg, corpID, userID)
|
|
if idx < 0 {
|
|
return nil
|
|
}
|
|
return &cfg.Profiles[idx]
|
|
}
|
|
|
|
func profilesForCorpID(cfg *ProfilesConfig, corpID string) []*Profile {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
corpID = strings.TrimSpace(corpID)
|
|
result := make([]*Profile, 0)
|
|
for i := range cfg.Profiles {
|
|
if strings.TrimSpace(cfg.Profiles[i].CorpID) == corpID {
|
|
result = append(result, &cfg.Profiles[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func unresolvedProfileForCorp(cfg *ProfilesConfig, corpID string) *Profile {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
corpID = strings.TrimSpace(corpID)
|
|
for i := range cfg.Profiles {
|
|
profile := &cfg.Profiles[i]
|
|
if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) == "" {
|
|
return profile
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func unresolvedProfileForLocalName(cfg *ProfilesConfig, name string) *Profile {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
var match *Profile
|
|
for i := range cfg.Profiles {
|
|
profile := &cfg.Profiles[i]
|
|
if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != name ||
|
|
len(profilesForCorpID(cfg, profile.CorpID)) <= 1 {
|
|
continue
|
|
}
|
|
if match != nil {
|
|
return nil
|
|
}
|
|
match = profile
|
|
}
|
|
return match
|
|
}
|
|
|
|
// When a blank profile coexists with exact accounts, the organization slot is
|
|
// that unresolved profile's only canonical credential. Exact identities must
|
|
// remain in their identity slots and may still become global current without
|
|
// overwriting the organization slot.
|
|
func shouldSyncOrganizationMirror(cfg *ProfilesConfig, profile Profile) bool {
|
|
return strings.TrimSpace(profile.UserID) == "" || unresolvedProfileForCorp(cfg, profile.CorpID) == nil
|
|
}
|
|
|
|
// validateIdentityOnlyProfileToken verifies the canonical token slot before a
|
|
// profile selection is persisted. This path is used only when an unresolved
|
|
// profile owns the organization slot, so an exact identity must not fall back
|
|
// to that slot. It is deliberately read-only: a rejected switch leaves every
|
|
// selection pointer and compatibility mirror untouched.
|
|
func validateIdentityOnlyProfileToken(profile Profile) error {
|
|
corpID := strings.TrimSpace(profile.CorpID)
|
|
userID := strings.TrimSpace(profile.UserID)
|
|
if corpID == "" || userID == "" {
|
|
return ErrTokenDataNotFound
|
|
}
|
|
data, err := profilesLoadIdentity(corpID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), err)
|
|
}
|
|
if data == nil {
|
|
return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), ErrTokenDataNotFound)
|
|
}
|
|
if !sameProfileIdentity(data.CorpID, data.UserID, corpID, userID) {
|
|
return fmt.Errorf(
|
|
"token in profile slot %q belongs to %q; identity does not match selected profile",
|
|
ProfileSelector(profile),
|
|
profileSelector(data.CorpID, data.UserID),
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool {
|
|
if cfg == nil {
|
|
return false
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if selector == "" {
|
|
return false
|
|
}
|
|
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
|
return unresolvedProfileForCorp(cfg, corpID) != nil
|
|
}
|
|
if unresolvedProfileForLocalName(cfg, selector) != nil {
|
|
return true
|
|
}
|
|
if corpID, userID, exact := ParseIdentitySelector(selector); exact {
|
|
if findExactProfile(cfg, corpID, userID) != nil {
|
|
return true
|
|
}
|
|
for i := range cfg.Profiles {
|
|
p := &cfg.Profiles[i]
|
|
orgMatches := strings.TrimSpace(p.CorpID) == corpID || strings.TrimSpace(p.CorpName) == corpID
|
|
accountMatches := strings.TrimSpace(p.UserID) == userID || strings.TrimSpace(p.UserName) == userID
|
|
if orgMatches && accountMatches {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if len(profilesForCorpID(cfg, selector)) > 0 {
|
|
return true
|
|
}
|
|
for i := range cfg.Profiles {
|
|
if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector ||
|
|
strings.TrimSpace(cfg.Profiles[i].Name) == selector {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func profileIdentityKey(corpID, userID string) string {
|
|
return strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID)
|
|
}
|
|
|
|
func sameProfileIdentity(leftCorpID, leftUserID, rightCorpID, rightUserID string) bool {
|
|
return profileIdentityKey(leftCorpID, leftUserID) == profileIdentityKey(rightCorpID, rightUserID)
|
|
}
|
|
|
|
func shortCorpID(corpID string) string {
|
|
corpID = strings.TrimSpace(corpID)
|
|
if len(corpID) <= 8 {
|
|
return corpID
|
|
}
|
|
return corpID[len(corpID)-8:]
|
|
}
|
|
|
|
func shortProfileID(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) <= 8 {
|
|
return value
|
|
}
|
|
return value[len(value)-8:]
|
|
}
|