Merge branch 'main' into fix/help-bar-search
CI / Checks (pull_request) Successful in 23s
PR title / Conventional title (pull_request) Successful in 1s

This commit is contained in:
keb committed 2026-10-11 10:09:42 +02:00
commit 62e4cbed09
12 files changed
+369 -20

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
package azure
import (
"fmt"
"slices"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// accountDetails describes an az account from az's account list and the
// claims of the latest token lazysecrets got for it: nothing is fetched
// for it.
type accountDetails struct {
account
claims func() map[string]any
}
var _ provider.Accounts = (*Provider)(nil)
// Account describes the az account, once a token has been fetched for it
// (the user's name and object ID come from the token).
func (p *Provider) Account() (provider.Account, bool) {
if p.account == nil {
return provider.Account{}, false
}
a := p.account
claims := a.claims()
str := func(k string) string { s, _ := claims[k].(string); return s }
var out provider.Account
add := func(label, value string) {
if value != "" {
out.Fields = append(out.Fields, provider.Attr{Label: label, Value: value})
}
}
add("name", str("name"))
user := a.user
if a.userType == "servicePrincipal" {
user += " (service principal)"
}
add("user", user)
tenant := a.tenant
if a.tenantName != "" {
tenant = a.tenantName + " · " + a.tenant
}
add("tenant", tenant)
if oid := str("oid"); oid != "" {
add("object ID", oid)
out.Copy, out.CopyLabel = oid, "object ID"
}
if claims != nil {
add("sign-in", signInMethods(claims["amr"]))
add("roles", directoryRoles(claims["wids"]))
}
subs := fmt.Sprint(a.subscriptions)
if a.defaultSub != "" {
subs += " · default " + a.defaultSub
}
add("subs", subs)
if a.cloud != "" && a.cloud != "AzureCloud" {
add("cloud", a.cloud)
}
return out, len(out.Fields) > 0
}
// signInMethods says how the session was signed in to, from the amr
// claim: with MFA or not, and by what.
func signInMethods(amr any) string {
list, _ := amr.([]any)
var methods []string
mfa := false
for _, m := range list {
s, _ := m.(string)
switch s {
case "mfa", "ngcmfa":
mfa = true
case "pwd":
methods = append(methods, "password")
case "rsa":
methods = append(methods, "Windows Hello or certificate")
case "fido":
methods = append(methods, "security key")
case "wia":
methods = append(methods, "Windows sign-in")
case "otp", "sms":
methods = append(methods, "one-time code")
}
}
if len(list) == 0 {
return ""
}
how := strings.Join(methods, ", ")
switch {
case mfa && how != "":
return "MFA · " + how
case mfa:
return "MFA"
case how != "":
return "no MFA · " + how
}
return "no MFA"
}
// builtinRoles names Entra ID's built-in directory roles by template ID,
// the IDs a token's wids claim lists.
var builtinRoles = map[string]string{
"62e90394-69f5-4237-9190-012177145e10": "Global Administrator",
"f2ef992c-3afb-46b9-b7cf-a126ee74c451": "Global Reader",
"e8611ab8-c189-46e8-94e1-60213ab1f814": "Privileged Role Administrator",
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13": "Privileged Authentication Administrator",
"194ae4cb-b126-40b2-bd5b-6091b380977d": "Security Administrator",
"5d6b6bb7-de71-4623-b4af-96380a352509": "Security Reader",
"5f2222b1-57c3-48ba-8ad5-d4759f1fde6f": "Security Operator",
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3": "Application Administrator",
"158c047a-c907-4556-b7ef-446551a6b5f7": "Cloud Application Administrator",
"cf1c38e5-3621-4004-a7cb-879624dced7c": "Application Developer",
"fe930be7-5e62-47db-91af-98c3a49a38b1": "User Administrator",
"fdd7a751-b60b-444a-984c-02652fe8fa1c": "Groups Administrator",
"729827e3-9c14-49f7-bb1b-9608f156bbb8": "Helpdesk Administrator",
"966707d0-3269-4727-9be2-8c3a10f19b9d": "Password Administrator",
"c4e39bd9-1100-46d3-8c65-fb160da0071f": "Authentication Administrator",
"0526716b-113d-4c15-b2c8-68e3c22b9f80": "Authentication Policy Administrator",
"8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2": "Hybrid Identity Administrator",
"b0f54661-2d74-4c50-afa3-1ec803f12efe": "Billing Administrator",
"4d6ac14f-3453-41d0-bef9-a3e0c569773a": "License Administrator",
"17315797-102d-40b4-93e0-432062caca18": "Compliance Administrator",
"29232cdf-9323-42fd-ade2-1d097af3e4de": "Exchange Administrator",
"f28a1f50-f6e7-4571-818b-6a12f2af6b6c": "SharePoint Administrator",
"69091246-20e8-4a56-aa4d-066075b2a7a8": "Teams Administrator",
"3a2c62db-5318-420d-8d74-23affee5d9d5": "Intune Administrator",
"11648597-926c-4cf3-9c36-bcebb0ba8dcc": "Power Platform Administrator",
"e3973bdf-4987-49ae-837a-ba8e231c7286": "Azure DevOps Administrator",
"9360feb5-f418-4baa-8175-e2a00bac4301": "Directory Writers",
"88d8e3e3-8f55-4a1e-953a-9b9898b8876b": "Directory Readers",
"95e79109-95c0-4d8e-aee3-d01accf2d47b": "Guest Inviter",
}
// memberRole is in every member's token: not worth listing.
const memberRole = "b79fbf4d-3ef9-4689-8143-76b194e85509"
// directoryRoles names the roles in a wids claim: built-in ones by name,
// others by their ID's first part.
func directoryRoles(wids any) string {
list, _ := wids.([]any)
var names []string
for _, w := range list {
id, _ := w.(string)
switch name, ok := builtinRoles[strings.ToLower(id)]; {
case id == "" || id == memberRole:
case ok:
names = append(names, name)
default:
short, _, _ := strings.Cut(id, "-")
names = append(names, "role "+short)
}
}
slices.Sort(names)
return strings.Join(names, ", ")
}
+67
View File
@@ -0,0 +1,67 @@
package azure
import (
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestAccount(t *testing.T) {
claims := map[string]any{
"name": "Alice Example", "oid": "11111111-2222-3333-4444-555555555555",
"amr": []any{"pwd", "mfa"},
"wids": []any{"62e90394-69f5-4237-9190-012177145e10", memberRole, "aaaaaaaa-0000-0000-0000-000000000000"},
}
p := &Provider{account: &accountDetails{
account: account{user: "alice@contoso.com", tenant: "t1", tenantName: "Contoso", subscriptions: 3, defaultSub: "platform"},
claims: func() map[string]any { return claims },
}}
a, ok := p.Account()
if !ok {
t.Fatal("no account")
}
got := map[string]string{}
for _, f := range a.Fields {
got[f.Label] = f.Value
}
want := map[string]string{
"name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso · t1",
"object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password",
"roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform",
}
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %q, want %q", k, got[k], v)
}
}
if len(got) != len(want) {
t.Errorf("fields %v", got)
}
if a.Copy != claims["oid"] || a.CopyLabel != "object ID" {
t.Errorf("copy %q %q", a.Copy, a.CopyLabel)
}
// Before any token: what az's list says.
claims = nil
a, _ = p.Account()
if len(a.Fields) != 3 || a.Copy != "" {
t.Errorf("without a token: %+v", a)
}
if _, ok := (&Provider{}).Account(); ok {
t.Error("a service principal from AZURE_* has no account details")
}
var _ provider.Accounts = p
}
func TestSignInMethods(t *testing.T) {
for want, amr := range map[string][]any{
"MFA · password": {"pwd", "mfa"},
"MFA · Windows Hello or certificate": {"rsa", "ngcmfa"},
"no MFA · password": {"pwd"},
"": nil,
} {
if got := signInMethods(amr); got != want {
t.Errorf("%v: %q, want %q", amr, got, want)
}
}
}
+18 -2
View File
@@ -19,7 +19,10 @@ import (
// 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
@@ -52,12 +55,15 @@ func readAccounts(path string) ([]account, error) {
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"`
}
@@ -72,7 +78,7 @@ func readAccounts(path string) ([]account, error) {
}
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})
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]
@@ -82,12 +88,21 @@ func readAccounts(path string) ([]account, error) {
enabled := s.State == "Enabled"
switch {
case s.IsDefault:
a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true
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
}
}
// az leaves the tenant's name out of some entries: take it from
// another in the same tenant.
for i := range out {
for _, b := range out {
if out[i].tenantName == "" && b.tenant == out[i].tenant {
out[i].tenantName = b.tenantName
}
}
}
slices.SortStableFunc(out, func(a, b account) int {
if a.isDefault != b.isDefault {
if a.isDefault {
@@ -179,6 +194,7 @@ func NewAccounts(s Settings) ([]*Provider, error) {
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
+2 -2
View File
@@ -181,11 +181,11 @@ func TestTokenUser(t *testing.T) {
`{"upn":"alice@contoso.com"}`: false,
`{"appid":"00000000-0000-0000-0000-000000000000"}`: false,
} {
if _, got := tokenUser(jwt(claims), users); got != want {
if _, got := tokenUser(tokenClaims(jwt(claims)), users); got != want {
t.Errorf("%s: %v", claims, got)
}
}
if _, got := tokenUser("not-a-jwt", users); got {
if _, got := tokenUser(tokenClaims("not-a-jwt"), users); got {
t.Error("not a JWT")
}
}
+5 -4
View File
@@ -36,10 +36,11 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p
type Provider struct {
id, label string // "" for the single-account defaults
cred azcore.TokenCredential
opts options
subs *armsubscriptions.Client
graph *armresourcegraph.Client
cred azcore.TokenCredential
opts options
account *accountDetails // nil for a service principal from AZURE_*
subs *armsubscriptions.Client
graph *armresourcegraph.Client
mu sync.Mutex
vaults map[string]*vaultClients // by vault URL
+28 -9
View File
@@ -42,6 +42,16 @@ type cliCredential struct {
mu sync.Mutex
cache map[string]azcore.AccessToken // by resource and tenant argument
// last is the claims of the latest token in the account's own
// tenant, to describe the account (Provider.Account).
last map[string]any
}
// claims are the latest token's claims, nil before the first.
func (c *cliCredential) claims() map[string]any {
c.mu.Lock()
defer c.mu.Unlock()
return c.last
}
func newCLICredential(user, tenant string, accounts func() ([]account, error)) *cliCredential {
@@ -110,29 +120,38 @@ func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOp
if err != nil {
return azcore.AccessToken{}, err
}
if u, ok := tokenUser(tok.Token, others); ok {
claims := tokenClaims(tok.Token)
if u, ok := tokenUser(claims, others); ok {
return azcore.AccessToken{}, fmt.Errorf("az gave a token for %s, not %s: sign in as %s again", u, c.user, c.user)
}
if !otherTenant && claims != nil {
c.last = claims
}
c.cache[key] = tok
return tok, nil
}
// tokenUser reports which of users a token was issued to, if any, from
// its claims. The token isn't verified: this only catches az picking the
// wrong signed-in account.
func tokenUser(token string, users []string) (string, bool) {
// tokenClaims reads a JWT's claims, without verifying it: they only
// describe the account and catch az picking the wrong one. nil if it
// isn't a JWT.
func tokenClaims(token string) map[string]any {
parts := strings.Split(token, ".")
if len(parts) != 3 || len(users) == 0 {
return "", false
if len(parts) != 3 {
return nil
}
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
if err != nil {
return "", false
return nil
}
var claims map[string]any
if json.Unmarshal(b, &claims) != nil {
return "", false
return nil
}
return claims
}
// tokenUser reports which of users a token's claims are for, if any.
func tokenUser(claims map[string]any, users []string) (string, bool) {
for _, name := range []string{"upn", "unique_name", "preferred_username", "email"} {
v, _ := claims[name].(string)
if i := strings.LastIndexByte(v, '#'); i >= 0 {
+14
View File
@@ -184,3 +184,17 @@ type File struct {
Line int // 1-based; 0 for the top
Sops string // the sops command to edit it with; empty for a plain file
}
// Accounts is implemented by providers that can describe the identity
// they use, from what they have locally: for the Details pane on the
// provider's row. ok is false when there's nothing to say.
type Accounts interface {
Account() (Account, bool)
}
// Account describes a signed-in identity.
type Account struct {
Fields []Attr
// Copy is what y copies on the provider's row, named CopyLabel.
Copy, CopyLabel string
}
+22
View File
@@ -723,6 +723,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.askHandlerSetup()
m.keys.Login.SetEnabled(m.loginFor() != nil)
m.detail.SetMessage(m.selectedProblem())
acct, _ := m.selectedAccount()
m.detail.SetAccount(acct.Fields)
if m.anyLoading() && !m.spinning {
m.spinning = true
cmds = append(cmds, m.spin.Tick)
@@ -1453,6 +1455,9 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd {
o, ok := m.detail.Secret()
if !ok {
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
return m.copyText(a.CopyLabel, a.Copy, false)
}
return nil
}
if v, ok := m.detail.Value(); ok {
@@ -1674,3 +1679,20 @@ func (m Model) loginFor() *provider.AuthError {
}
return nil
}
// selectedAccount describes the account of the selected provider row, if
// its provider can.
func (m Model) selectedAccount() (provider.Account, bool) {
r, ok := m.sources.Selected()
if !ok || r.kind != rowGroup {
return provider.Account{}, false
}
if res := m.sourceResults[r.provider]; res.err != nil {
return provider.Account{}, false // the problem shows instead
}
a, ok := m.byID[r.provider].(provider.Accounts)
if !ok {
return provider.Account{}, false
}
return a.Account()
}
+30
View File
@@ -1069,3 +1069,33 @@ func lastLines(scr string, n int) string {
lines := strings.Split(strings.TrimRight(scr, "\n"), "\n")
return strings.Join(lines[max(0, len(lines)-n):], "\n")
}
// described is an account that can say who it is.
type described struct{ provider.Provider }
func (described) Account() (provider.Account, bool) {
return provider.Account{
Fields: []provider.Attr{{Label: "name", Value: "Alice Example"}, {Label: "object ID", Value: "oid-123"}},
Copy: "oid-123", CopyLabel: "object ID",
}, true
}
func TestAccountInDetails(t *testing.T) {
t.Parallel()
d := newDriver(t, described{fake.Azure(0)})
d.keys("g") // the group's row
if r, _ := d.m.sources.Selected(); r.kind != rowGroup {
t.Fatalf("not on the group: %+v", r)
}
if scr := d.screen(); !strings.Contains(scr, "Alice Example") || !strings.Contains(scr, "oid-123") {
t.Errorf("account not in Details:\n%s", scr)
}
d.keys("y")
if got := d.clip().content; got != "oid-123" {
t.Errorf("y copied %q", got)
}
d.keys("j")
if strings.Contains(d.screen(), "Alice Example") {
t.Errorf("still shown on a subscription:\n%s", d.screen())
}
}
+11
View File
@@ -47,6 +47,7 @@ type Detail struct {
versions []provider.Object // newest first; nil while unknown
note string // the user's note on what's shown
message string // shown instead of the hint when nothing is selected: an error
account []provider.Attr // shown instead of the hint on a provider's row: who it signs in as
offset int
w, h int
@@ -76,6 +77,10 @@ func (d *Detail) Clear() {
// selected, such as why an account has nothing to show; "" for the hint.
func (d *Detail) SetMessage(s string) { d.message = s }
// SetAccount sets the account shown on a provider's row when nothing is
// selected; nil for the hint.
func (d *Detail) SetAccount(fields []provider.Attr) { d.account = fields }
// SetDescribing marks extra metadata as being fetched.
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
@@ -191,6 +196,12 @@ func (d Detail) lines(st styles.Styles) []string {
muted := st.Muted.Render
if d.container == nil {
if d.message == "" && len(d.account) > 0 {
for _, a := range d.account {
field(a.Label, a.Value)
}
return out
}
if d.message != "" {
for _, l := range strings.Split(ansi.Hardwrap(d.message, max(1, inner-2), true), "\n") {
out = append(out, " "+st.Error.Render(l))