From 482e0805b7c2e068c91b5095b3d8ab1da3c6826f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 10:06:41 +0200 Subject: [PATCH 1/2] wip: account details from az and the token --- internal/provider/azure/account.go | 160 +++++++++++++++++++++++ internal/provider/azure/accounts.go | 11 +- internal/provider/azure/accounts_test.go | 4 +- internal/provider/azure/azure.go | 9 +- internal/provider/azure/cli.go | 37 ++++-- internal/provider/provider.go | 14 ++ 6 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 internal/provider/azure/account.go diff --git a/internal/provider/azure/account.go b/internal/provider/azure/account.go new file mode 100644 index 0000000..7371203 --- /dev/null +++ b/internal/provider/azure/account.go @@ -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, ", ") +} diff --git a/internal/provider/azure/accounts.go b/internal/provider/azure/accounts.go index 1c6d194..208bb1a 100644 --- a/internal/provider/azure/accounts.go +++ b/internal/provider/azure/accounts.go @@ -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,7 +88,7 @@ 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 @@ -179,6 +185,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 diff --git a/internal/provider/azure/accounts_test.go b/internal/provider/azure/accounts_test.go index b9337de..88eb080 100644 --- a/internal/provider/azure/accounts_test.go +++ b/internal/provider/azure/accounts_test.go @@ -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") } } diff --git a/internal/provider/azure/azure.go b/internal/provider/azure/azure.go index a140dad..1d50999 100644 --- a/internal/provider/azure/azure.go +++ b/internal/provider/azure/azure.go @@ -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 diff --git a/internal/provider/azure/cli.go b/internal/provider/azure/cli.go index e37508c..aced535 100644 --- a/internal/provider/azure/cli.go +++ b/internal/provider/azure/cli.go @@ -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 { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0b4b918..0857baf 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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 +} -- 2.54.0 From 2e941102a2d3d026398dddaa6d654e392e11bec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 10:08:04 +0200 Subject: [PATCH 2/2] feat: show who an Azure account signs in as, in Details on its row Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/azure.md | 13 ++++- docs/keys.md | 2 +- internal/provider/azure/account_test.go | 67 +++++++++++++++++++++++++ internal/provider/azure/accounts.go | 9 ++++ internal/ui/app/app.go | 22 ++++++++ internal/ui/app/app_test.go | 30 +++++++++++ internal/ui/panes/detail.go | 11 ++++ 7 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 internal/provider/azure/account_test.go diff --git a/docs/azure.md b/docs/azure.md index ebe9744..75bc2b4 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -13,8 +13,17 @@ is listed under each, since their permissions can differ. A service principal is used instead when `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and a secret or certificate are set in the environment. -If your az login expires mid-session, the status line says so: press `L` to -run `az login` without leaving lazysecrets, and everything reloads. +On an account's group row, the Details pane shows who it signs in as: name, +user, tenant, object ID, whether the session used MFA, directory roles +(built-in ones by name) and its subscriptions. It's read from az's account +list and the token lazysecrets already has, so nothing is fetched for it. +`y` there copies the object ID, the one access policies and role +assignments ask for. + +If an account's az login has expired, the status line says so once, and the +Details pane on that account's rows says it until you sign in: press `L` +there to run `az login` for it without leaving lazysecrets, and everything +reloads. ## Permissions diff --git a/docs/keys.md b/docs/keys.md index 337b24e..5bf842f 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -48,7 +48,7 @@ match case exactly. | Key | Action | |---|---| | `space` | reveal / hide the selected value (public material for keys and certificates) | -| `y` | copy the value without revealing it; cleared from the clipboard after 30s | +| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `o` | open the selected item in the browser (over SSH: copy the link) | diff --git a/internal/provider/azure/account_test.go b/internal/provider/azure/account_test.go new file mode 100644 index 0000000..c82e32f --- /dev/null +++ b/internal/provider/azure/account_test.go @@ -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) + } + } +} diff --git a/internal/provider/azure/accounts.go b/internal/provider/azure/accounts.go index 208bb1a..e760b82 100644 --- a/internal/provider/azure/accounts.go +++ b/internal/provider/azure/accounts.go @@ -94,6 +94,15 @@ func readAccounts(path string) ([]account, error) { 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 { diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 01afc31..2e1eee2 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -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() +} diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index a122c1a..d952d6b 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -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()) + } +} diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index f1f2e15..dbdab8c 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -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)) -- 2.54.0