197 lines
5.4 KiB
Go
197 lines
5.4 KiB
Go
package azure
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
)
|
|
|
|
// account is one az CLI identity in one tenant.
|
|
type account struct {
|
|
user, tenant string
|
|
userType string // "user" or "servicePrincipal"
|
|
tenantName string
|
|
cloud string // az's environment: AzureCloud, AzureChinaCloud, …
|
|
defaultSub string // az's default subscription's name, if this account's
|
|
anchor string // a subscription az maps to this account, to pin it
|
|
anchorEnabled bool
|
|
isDefault bool
|
|
subscriptions int // how many subscriptions az maps to it
|
|
}
|
|
|
|
// azProfilePath is az's account list. It holds no tokens.
|
|
func azProfilePath() (string, error) {
|
|
dir := os.Getenv("AZURE_CONFIG_DIR")
|
|
if dir == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir = filepath.Join(home, ".azure")
|
|
}
|
|
return filepath.Join(dir, "azureProfile.json"), nil
|
|
}
|
|
|
|
// readAccounts lists the accounts signed in to az, default first.
|
|
func readAccounts(path string) ([]account, error) {
|
|
b, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")) // az writes a BOM
|
|
var profile struct {
|
|
Subscriptions []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
State string `json:"state"`
|
|
IsDefault bool `json:"isDefault"`
|
|
TenantID string `json:"tenantId"`
|
|
TenantName string `json:"tenantDisplayName"`
|
|
Cloud string `json:"environmentName"`
|
|
User struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
} `json:"user"`
|
|
} `json:"subscriptions"`
|
|
}
|
|
if err := json.Unmarshal(b, &profile); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
|
|
var out []account
|
|
for _, s := range profile.Subscriptions {
|
|
if s.User.Name == "" || s.TenantID == "" {
|
|
continue
|
|
}
|
|
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
|
|
if i < 0 {
|
|
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName, userType: s.User.Type, cloud: s.Cloud})
|
|
i = len(out) - 1
|
|
}
|
|
a := &out[i]
|
|
a.subscriptions++
|
|
// Anchor on az's default subscription if it is this account's,
|
|
// otherwise on an enabled one.
|
|
enabled := s.State == "Enabled"
|
|
switch {
|
|
case s.IsDefault:
|
|
a.anchor, a.anchorEnabled, a.isDefault, a.defaultSub = s.ID, enabled, true, s.Name
|
|
case a.isDefault:
|
|
case a.anchor == "" || enabled && !a.anchorEnabled:
|
|
a.anchor, a.anchorEnabled = s.ID, enabled
|
|
}
|
|
}
|
|
slices.SortStableFunc(out, func(a, b account) int {
|
|
if a.isDefault != b.isDefault {
|
|
if a.isDefault {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
return cmp.Compare(a.user, b.user)
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// accountLabels names accounts as briefly as stays unambiguous: the user
|
|
// name before the @, the full user if another user shares that, plus the
|
|
// tenant if one user is signed in to several.
|
|
func accountLabels(accounts []account) []string {
|
|
short := func(u string) string { s, _, _ := strings.Cut(u, "@"); return s }
|
|
labels := make([]string, len(accounts))
|
|
for i, a := range accounts {
|
|
l, tenants := short(a.user), 0
|
|
for _, b := range accounts {
|
|
if b.user != a.user && short(b.user) == short(a.user) {
|
|
l = a.user
|
|
}
|
|
if b.user == a.user {
|
|
tenants++
|
|
}
|
|
}
|
|
if tenants > 1 {
|
|
l += " · " + cmp.Or(a.tenantName, a.tenant)
|
|
}
|
|
labels[i] = l
|
|
}
|
|
return labels
|
|
}
|
|
|
|
// NewAccounts returns one provider per account signed in to az, the default
|
|
// first, or a single provider if AZURE_* configures a service principal or
|
|
// az has no accounts yet. Pinned vaults go to the first.
|
|
func NewAccounts(s Settings) ([]*Provider, error) {
|
|
if os.Getenv("AZURE_CLIENT_ID") != "" && os.Getenv("AZURE_TENANT_ID") != "" {
|
|
cred, err := azidentity.NewEnvironmentCredential(nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := newWithOptions(authCredential{cred: cred}, options{Settings: s})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []*Provider{p}, nil
|
|
}
|
|
|
|
path, err := azProfilePath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accounts, err := readAccounts(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(accounts) == 0 {
|
|
// Not signed in yet: the first call fails with an AuthError and
|
|
// the UI offers az login.
|
|
cred := authCredential{cred: newCLICredential("", "", nil), login: []string{"az", "login"}}
|
|
p, err := newWithOptions(cred, options{Settings: s})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []*Provider{p}, nil
|
|
}
|
|
|
|
labels := accountLabels(accounts)
|
|
var out []*Provider
|
|
for i, a := range accounts {
|
|
settings := s
|
|
if i > 0 {
|
|
settings.Vaults = nil
|
|
}
|
|
label := ""
|
|
if len(accounts) > 1 {
|
|
label = string(provider.KindAzure) + " · " + labels[i]
|
|
}
|
|
cred := authCredential{
|
|
cred: newCLICredential(a.user, a.tenant, func() ([]account, error) { return readAccounts(path) }),
|
|
login: []string{"az", "login", "--tenant", a.tenant},
|
|
account: label, // so "sign-in required" says which account
|
|
}
|
|
p, err := newWithOptions(cred, options{Settings: settings})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.account = &accountDetails{account: a, claims: cred.cred.(*cliCredential).claims}
|
|
if label != "" {
|
|
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
|
|
p.label = label
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, nil
|
|
}
|