From 5381cf5d43c95acb1fb55bd66233bf00bdf1b647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:41:12 +0200 Subject: [PATCH 1/3] feat: vaults, scopes and files show their details: a vault's settings, who has a scope, a file's keys and git status Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/azure.md | 7 ++ docs/databricks.md | 4 + docs/repos.md | 7 ++ internal/provider/azure/azure_test.go | 36 ++++++ internal/provider/azure/details.go | 106 ++++++++++++++++++ .../provider/databricks/databricks_test.go | 24 ++++ internal/provider/databricks/details.go | 43 +++++++ internal/provider/provider.go | 9 ++ internal/provider/repo/details.go | 62 ++++++++++ internal/provider/repo/repo.go | 65 ++++++++++- internal/provider/repo/repo_test.go | 44 ++++++++ internal/ui/app/app.go | 6 + internal/ui/app/app_test.go | 19 ++++ internal/ui/app/fields.go | 44 ++++++++ 14 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 internal/provider/azure/details.go create mode 100644 internal/provider/databricks/details.go create mode 100644 internal/provider/repo/details.go diff --git a/docs/azure.md b/docs/azure.md index 85a9e91..5c0c423 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -64,3 +64,10 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane key (PEM public key) or certificate; private key material is never fetched. `v` lists an item's versions. Items expiring within `expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`. + +On a vault, the right pane shows its settings from the Key Vault +management API: resource group, location, SKU, whether access is by Azure +roles or access policies, soft delete and purge protection, which networks +can reach it, private endpoints and tags. They load the first time you're +on the vault (`r` in the Vaults pane loads them again), and need read +access to the vault resource, as discovering it does. diff --git a/docs/databricks.md b/docs/databricks.md index d44ca7d..e02fc74 100644 --- a/docs/databricks.md +++ b/docs/databricks.md @@ -13,6 +13,10 @@ Secrets in Azure Key Vault-backed scopes are read through Databricks; if Databricks refuses, lazysecrets reads the backing vault directly with your az login (which then needs the [Key Vault roles](azure.md#permissions)). +On a scope, the right pane shows who has access to it and how much +(manage, write, read). Databricks shows that only to the scope's +managers; to others it says so. + Hide profiles you don't want in the [config](config.md): ```yaml diff --git a/docs/repos.md b/docs/repos.md index 47c837b..c3c6878 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -63,6 +63,13 @@ copying or `lazysecrets get` decrypts one value with your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP KMS, PGP). When sops can't decrypt, its whole error message is shown. +On a file, the right pane shows how it's encrypted and the keys it's +encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP +fingerprints), when it was last encrypted, and what git knows of it: +tracked, ignored or neither, and its last commit. A plain `.env` file that +git tracks, or that isn't ignored, is marked in red: its values are in the +repo's history, or easily could be. + ## Opening files `e` opens the selected file, or on a secret, its file at that key's line: diff --git a/internal/provider/azure/azure_test.go b/internal/provider/azure/azure_test.go index 4f6bc03..fc21f3c 100644 --- a/internal/provider/azure/azure_test.go +++ b/internal/provider/azure/azure_test.go @@ -19,6 +19,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "slices" "strings" "testing" "time" @@ -417,3 +418,38 @@ func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) { size := (len(b) - 1) / 2 return b[1 : 1+size], b[1+size:] } + +func TestVaultDetails(t *testing.T) { + id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv" + p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != id { + http.NotFound(w, r) + return + } + writeJSON(w, 200, map[string]any{"id": id, "name": "kv", "location": "norwayeast", + "tags": map[string]any{"team": "platform", "env": "prod"}, + "properties": map[string]any{ + "tenantId": "t", "sku": map[string]any{"family": "A", "name": "premium"}, + "enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30, + "networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}}, + "privateEndpointConnections": []any{map[string]any{"id": "pe1"}}, + }}) + }) + attrs, err := p.Details(context.Background(), provider.Container{ID: id}) + if err != nil { + t.Fatal(err) + } + var got []string + for _, a := range attrs { + got = append(got, a.Label+"="+a.Value) + } + want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)", + "soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)", + "private=1 endpoint", "tags=env: prod", "=team: platform"} + if !slices.Equal(got, want) { + t.Errorf("details\n got %q\nwant %q", got, want) + } + if _, err := p.Details(context.Background(), provider.Container{ID: "https://kv.vault.azure.net/"}); !errors.Is(err, provider.ErrUnsupported) { + t.Errorf("pinned vault: %v", err) + } +} diff --git a/internal/provider/azure/details.go b/internal/provider/azure/details.go new file mode 100644 index 0000000..89cb630 --- /dev/null +++ b/internal/provider/azure/details.go @@ -0,0 +1,106 @@ +package azure + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Details are a vault's settings, from the Key Vault management API: where +// it is, how access to it works, and how it's protected. Pinned vaults, +// known only by their URL, have none. +func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) { + id, err := arm.ParseResourceID(c.ID) + if err != nil { + return nil, provider.ErrUnsupported + } + client, err := armkeyvault.NewVaultsClient(id.SubscriptionID, p.cred, p.opts.arm) + if err != nil { + return nil, err + } + resp, err := client.Get(ctx, id.ResourceGroupName, id.Name, nil) + if err != nil { + return nil, cleanError(err) + } + return vaultAttrs(resp.Vault), nil +} + +func vaultAttrs(v armkeyvault.Vault) []provider.Attr { + var out []provider.Attr + add := func(label, value string) { + if value != "" { + out = append(out, provider.Attr{Label: label, Value: value}) + } + } + if id, err := arm.ParseResourceID(deref(v.ID)); err == nil { + add("group", id.ResourceGroupName) + } + add("location", deref(v.Location)) + pr := v.Properties + if pr == nil { + return out + } + if pr.SKU != nil && pr.SKU.Name != nil { + add("SKU", string(*pr.SKU.Name)) + } + // Who may read secrets is decided by Azure roles or by the vault's + // own access policies, never both. + if on(pr.EnableRbacAuthorization, false) { + add("access", "Azure roles (RBAC)") + } else { + add("access", fmt.Sprintf("access policies (%d)", len(pr.AccessPolicies))) + } + if on(pr.EnableSoftDelete, true) { + days := int32(90) + if pr.SoftDeleteRetentionInDays != nil { + days = *pr.SoftDeleteRetentionInDays + } + add("soft delete", fmt.Sprintf("on, %d days", days)) + } else { + add("soft delete", "off") + } + add("purge prot.", map[bool]string{true: "on", false: "off"}[on(pr.EnablePurgeProtection, false)]) + add("network", network(pr)) + if n := len(pr.PrivateEndpointConnections); n > 0 { + add("private", fmt.Sprintf("%d endpoint%s", n, map[bool]string{true: "s"}[n > 1])) + } + keys := make([]string, 0, len(v.Tags)) + for k := range v.Tags { + keys = append(keys, k) + } + slices.Sort(keys) + for i, k := range keys { + label := "" + if i == 0 { + label = "tags" + } + out = append(out, provider.Attr{Label: label, Value: k + ": " + deref(v.Tags[k])}) + } + return out +} + +// network is where the vault can be reached from. +func network(pr *armkeyvault.VaultProperties) string { + if strings.EqualFold(deref(pr.PublicNetworkAccess), "disabled") { + return "private endpoints only" + } + acl := pr.NetworkACLs + if acl == nil || acl.DefaultAction == nil || *acl.DefaultAction != armkeyvault.NetworkRuleActionDeny { + return "all networks" + } + return fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", len(acl.IPRules), len(acl.VirtualNetworkRules)) +} + +// on is *b, or def when it isn't set. +func on(b *bool, def bool) bool { + if b == nil { + return def + } + return *b +} diff --git a/internal/provider/databricks/databricks_test.go b/internal/provider/databricks/databricks_test.go index 5c47f6b..0df6bf0 100644 --- a/internal/provider/databricks/databricks_test.go +++ b/internal/provider/databricks/databricks_test.go @@ -140,6 +140,13 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server { map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000}, map[string]any{"key": "client-secret"}, }}) + case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "app": + write(w, 200, map[string]any{"items": []any{ + map[string]any{"principal": "users", "permission": "READ"}, + map[string]any{"principal": "admins", "permission": "MANAGE"}, + }}) + case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "locked": + write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have MANAGE permission"}) case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app": write(w, 200, map[string]any{"key": q.Get("key"), "value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))}) @@ -291,3 +298,20 @@ func TestClassify(t *testing.T) { t.Error("cancellation classified as auth") } } + +func TestScopeDetails(t *testing.T) { + p := newTestProvider(t, workspaceAPI(t, 0), nil) + src := provider.Source{ID: "prod"} + attrs, err := p.Details(context.Background(), provider.Container{Source: src, Name: "app"}) + if err != nil { + t.Fatal(err) + } + want := []provider.Attr{{Label: "access", Value: "admins · manage"}, {Value: "users · read"}} + if !reflect.DeepEqual(attrs, want) { + t.Errorf("app: %+v", attrs) + } + attrs, err = p.Details(context.Background(), provider.Container{Source: src, Name: "locked"}) + if err != nil || len(attrs) != 1 || !strings.Contains(attrs[0].Value, "managers") { + t.Errorf("locked: %+v, %v", attrs, err) + } +} diff --git a/internal/provider/databricks/details.go b/internal/provider/databricks/details.go new file mode 100644 index 0000000..dac37a9 --- /dev/null +++ b/internal/provider/databricks/details.go @@ -0,0 +1,43 @@ +package databricks + +import ( + "cmp" + "context" + "errors" + "slices" + "strings" + + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/service/workspace" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Details are who has access to a scope, and how much. Only the scope's +// managers may see that; to others it says so. +func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) { + w, pr, err := p.client(c.Source.ID) + if err != nil { + return nil, err + } + acls, err := w.ListAclsAll(ctx, workspace.ListAclsRequest{Scope: c.Name}) + var api *apierr.APIError + switch { + case errors.As(err, &api) && api.StatusCode == 403: + return []provider.Attr{{Label: "access", Value: "only the scope's managers can see who has it"}}, nil + case err != nil: + return nil, classify(pr, err) + case len(acls) == 0: + return []provider.Attr{{Label: "access", Value: "workspace admins only"}}, nil + } + rank := map[workspace.AclPermission]int{workspace.AclPermissionManage: 0, workspace.AclPermissionWrite: 1, workspace.AclPermissionRead: 2} + slices.SortFunc(acls, func(a, b workspace.AclItem) int { + return cmp.Or(cmp.Compare(rank[a.Permission], rank[b.Permission]), cmp.Compare(a.Principal, b.Principal)) + }) + out := make([]provider.Attr, len(acls)) + for i, a := range acls { + out[i] = provider.Attr{Value: a.Principal + " · " + strings.ToLower(string(a.Permission))} + } + out[0].Label = "access" + return out, nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 2cce540..3affae6 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -107,6 +107,7 @@ func (c Container) ObjectTypes() []ObjectType { type Attr struct { Label string Value string + Warn bool // worth a look (a plain .env tracked by git): shown in red } // Object describes a secret, key or certificate without its value. @@ -199,6 +200,14 @@ type Account struct { Copy, CopyLabel string } +// ContainerDetails is implemented by providers that can say more about a +// container than its listing does: a vault's settings, who has access to +// a scope, how a file is encrypted and what git knows of it. It may go +// over the network; ErrUnsupported means there's nothing more for c. +type ContainerDetails interface { + Details(ctx context.Context, c Container) ([]Attr, error) +} + // AccountGroups is implemented by providers that can list the groups // their account is a member of, directly or through other groups. It // goes over the network. diff --git a/internal/provider/repo/details.go b/internal/provider/repo/details.go new file mode 100644 index 0000000..44e066d --- /dev/null +++ b/internal/provider/repo/details.go @@ -0,0 +1,62 @@ +package repo + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Details are how a file is encrypted and for whom, and what git knows of +// it: a plain .env file that git tracks, or could, is marked. +func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) { + r, ok := p.repo(c.Source.ID) + if !ok { + return nil, provider.ErrUnsupported + } + rel, err := filepath.Rel(r.root, c.ID) + if err != nil { + return nil, err + } + var out []provider.Attr + meta, isSops := readMeta(c.ID) + if isSops { + out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")}) + for i, k := range meta.recipients { + label := "" + if i == 0 { + label = "keys" + } + out = append(out, provider.Attr{Label: label, Value: k}) + } + if !meta.modified.IsZero() { + out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")}) + } + } else { + out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"}) + } + + git := func(args ...string) (string, bool) { + b, err := exec.CommandContext(ctx, "git", append([]string{"-C", r.root}, args...)...).Output() + return strings.TrimSpace(string(b)), err == nil + } + switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); { + case tracked && !isSops: + out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true}) + case tracked: + out = append(out, provider.Attr{Label: "git", Value: "tracked"}) + default: + if _, ignored := git("check-ignore", "-q", "--", rel); ignored { + out = append(out, provider.Attr{Label: "git", Value: "ignored"}) + } else { + out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops}) + } + return out, nil + } + if last, ok := git("log", "-1", "--format=%h · %ar · %an: %s", "--", rel); ok && last != "" { + out = append(out, provider.Attr{Label: "last commit", Value: last}) + } + return out, nil +} diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index d306433..424a02a 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -815,8 +815,30 @@ func anyMatch(res []*regexp.Regexp, f string) bool { // --- sops files --- type sopsMeta struct { - methods []string // age, azure_kv, … - modified time.Time + methods []string // age, azure_kv, … + recipients []string // "age age1…", "azure_kv https://kv…/keys/sops" + modified time.Time +} + +// recipient names a key a file is encrypted for, from its sops metadata: +// public, never a secret. +func recipient(method string, f map[string]string) string { + var v string + switch method { + case "age": + v = f["recipient"] + case "pgp": + v = f["fp"] + case "kms": + v = f["arn"] + case "gcp_kms": + v = f["resource_id"] + case "azure_kv": + v = strings.TrimSuffix(f["vault_url"], "/") + "/keys/" + f["name"] + case "hc_vault": + v = strings.TrimSuffix(f["vault_address"], "/") + " " + f["engine_path"] + "/" + f["key_name"] + } + return method + " " + v } const maxFileSize = 4 << 20 @@ -848,6 +870,15 @@ func readMeta(file string) (sopsMeta, bool) { for _, k := range known { if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 { m.methods = append(m.methods, k) + for _, e := range v { + fields := map[string]string{} + if em, ok := e.(map[string]any); ok { + for fk, fv := range em { + fields[fk] = fmt.Sprint(fv) + } + } + m.recipients = append(m.recipients, recipient(k, fields)) + } } } if s, ok := doc.Sops["lastmodified"].(string); ok { @@ -864,6 +895,7 @@ func readMeta(file string) (sopsMeta, bool) { for _, k := range known { if strings.Contains(text, "sops_"+k+"__") { m.methods = append(m.methods, k) + m.recipients = append(m.recipients, envRecipients(text, k)...) } } if i := strings.Index(text, "sops_lastmodified"); i >= 0 { @@ -881,6 +913,35 @@ func readMeta(file string) (sopsMeta, bool) { return m, true } +// envRecipients are method's keys in a dotenv or ini sops file, whose +// metadata is flattened: sops_age__list_0__map_recipient=age1…. +func envRecipients(text, method string) []string { + prefix := "sops_" + method + "__list_" + var order []string + keys := map[string]map[string]string{} + for line := range strings.Lines(text) { + k, v, ok := strings.Cut(strings.TrimSpace(line), "=") + rest, found := strings.CutPrefix(strings.TrimSpace(k), prefix) + if !ok || !found { + continue + } + n, field, ok := strings.Cut(rest, "__map_") + if !ok { + continue + } + if keys[n] == nil { + keys[n] = map[string]string{} + order = append(order, n) + } + keys[n][field] = strings.TrimSpace(v) + } + out := make([]string, len(order)) + for i, n := range order { + out[i] = recipient(method, keys[n]) + } + return out +} + func formatOf(file string) string { switch strings.ToLower(filepath.Ext(file)) { case ".yaml", ".yml": diff --git a/internal/provider/repo/repo_test.go b/internal/provider/repo/repo_test.go index b7b2f09..5c599f7 100644 --- a/internal/provider/repo/repo_test.go +++ b/internal/provider/repo/repo_test.go @@ -477,3 +477,47 @@ func TestFile(t *testing.T) { t.Errorf("lines %v\nwant %v", lines, want) } } + +func TestFileDetails(t *testing.T) { + root := gitRepo(t, map[string]string{ + ".gitignore": ".env\n", + "secrets/app.yaml": sopsYAML, + "prod.env": sopsEnv, + "tracked.env": "TOKEN=x\n", // a plain .env in git: worth a look + }) + os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=x\n"), 0o600) + os.WriteFile(filepath.Join(root, "local.env"), []byte("TOKEN=x\n"), 0o600) + p := New(Settings{Dir: root}) + ctx := context.Background() + srcs, _ := p.Sources(ctx) + details := func(name string) map[string]provider.Attr { + t.Helper() + attrs, err := p.Details(ctx, provider.Container{Source: srcs[0], ID: filepath.Join(root, name), Name: name}) + if err != nil { + t.Fatal(err) + } + out := map[string]provider.Attr{} + for _, a := range attrs { + out[a.Label] = a + } + return out + } + + d := details("secrets/app.yaml") + if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" || + !strings.Contains(d["last commit"].Value, "t: files") || d["encrypted"].Value == "" { + t.Errorf("sops yaml: %+v", d) + } + if d := details("prod.env"); d["keys"].Value != "age age1xxxx" { + t.Errorf("sops env: %+v", d) + } + if d := details("tracked.env"); d["encryption"].Value != "none: plain text" || !d["git"].Warn { + t.Errorf("tracked plain env: %+v", d) + } + if d := details(".env"); d["git"].Value != "ignored" || d["git"].Warn { + t.Errorf("ignored env: %+v", d) + } + if d := details("local.env"); !d["git"].Warn || !strings.Contains(d["git"].Value, "not ignored") { + t.Errorf("untracked env: %+v", d) + } +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 7b0880c..8063a18 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -177,6 +177,7 @@ type Model struct { fields panes.Fields lastLeft pane groups map[string]groupsState + details map[string]detailsState // by containerKey getenv func(string) string nvimOpen func(server, path string, line int) error // edit.go now func() time.Time @@ -376,6 +377,7 @@ func New(providers []provider.Provider, opts Options) Model { nvimOpen: nvimOpen, cache: newCache(), groups: map[string]groupsState{}, + details: map[string]detailsState{}, fields: panes.NewFields(), } if m.clip == nil { @@ -588,6 +590,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case groupsMsg: m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err} + case detailsMsg: + m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err} + case crawlMsg: cmds = append(cmds, m.handleCrawl(msg)) @@ -1032,6 +1037,7 @@ func (m *Model) refresh() tea.Cmd { case paneContainers: if src, ok := m.selectedSource(); ok { m.containers.Refresh() + m.details = map[string]detailsState{} return m.loadContainers(src, m.selSource) } case paneSecrets: diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index e526a91..690e871 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1236,3 +1236,22 @@ func TestStatusPane(t *testing.T) { t.Errorf("back to %v", d.m.focus) } } + +// detailed is an account whose vaults have details. +type detailed struct{ provider.Provider } + +func (detailed) Details(_ context.Context, c provider.Container) ([]provider.Attr, error) { + return []provider.Attr{{Label: "access", Value: "Azure roles (RBAC)"}, {Label: "git", Value: "tracked", Warn: true}}, nil +} + +func TestContainerDetails(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{}, detailed{fake.Azure(0)}) + d.keys("2") + if scr := d.screen(); !strings.Contains(scr, "access Azure roles (RBAC)") || !strings.Contains(scr, "URI") { + t.Errorf("details:\n%s", scr) + } + if st := d.m.details[d.m.containers.SelectedID()]; st.loading || len(st.attrs) != 2 { + t.Errorf("state %+v", st) + } +} diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index 6a0f253..f40c162 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -29,6 +29,20 @@ type groupsMsg struct { err error } +// A vault's, scope's or file's details beyond its listing load once a +// session, the first time it's shown, and again with refresh. +type detailsState struct { + attrs []provider.Attr + err error + loading bool +} + +type detailsMsg struct { + key string + attrs []provider.Attr + err error +} + // detailOf is the pane the fourth pane describes: the one in focus, or, // with the fourth in focus, the one it was entered from. func (m Model) detailOf() pane { @@ -68,7 +82,9 @@ func (m *Model) syncFields() tea.Cmd { m.fields.Show("lazysecrets", m.statusFields()) case paneContainers: if c, ok := m.containers.Selected(); ok { + cmd := m.loadDetails(c) m.fields.Show(c.Name, m.containerFields(c)) + return cmd } else { m.fields.Show("", nil) } @@ -171,9 +187,37 @@ func (m Model) containerFields(c provider.Container) []panes.Field { add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value))) } } + switch st := m.details[containerKey(c)]; { + case st.loading: + out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true}) + case errors.Is(st.err, provider.ErrUnsupported): + case st.err != nil: + out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true}) + } + for _, a := range m.details[containerKey(c)].attrs { + out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn}) + } return out } +// loadDetails loads c's details, if its provider has them and they +// aren't loaded yet. +func (m *Model) loadDetails(c provider.Container) tea.Cmd { + d, ok := m.providerFor(c.Source).(provider.ContainerDetails) + key := containerKey(c) + if _, seen := m.details[key]; !ok || seen { + return nil + } + m.details[key] = detailsState{loading: true} + timeout := m.timeout + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + attrs, err := d.Details(ctx, c) + return detailsMsg{key: key, attrs: attrs, err: err} + } +} + // problemFields show why an account has nothing to show, wrapped to the // pane, in red. func (m Model) problemFields(problem string) []panes.Field { From 879b961ac5bbcf8c10afdfd45b8506feff76805e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:42:48 +0200 Subject: [PATCH 2/3] feat: a vault's settings come with the vault list, with no requests of their own Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/azure.md | 11 +- internal/provider/azure/azure.go | 8 +- internal/provider/azure/azure_test.go | 67 ++++++----- internal/provider/azure/details.go | 160 +++++++++++++++++--------- internal/provider/provider.go | 2 + internal/ui/app/fields.go | 3 + 6 files changed, 157 insertions(+), 94 deletions(-) diff --git a/docs/azure.md b/docs/azure.md index 5c0c423..8488ee8 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -65,9 +65,8 @@ key (PEM public key) or certificate; private key material is never fetched. `v` lists an item's versions. Items expiring within `expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`. -On a vault, the right pane shows its settings from the Key Vault -management API: resource group, location, SKU, whether access is by Azure -roles or access policies, soft delete and purge protection, which networks -can reach it, private endpoints and tags. They load the first time you're -on the vault (`r` in the Vaults pane loads them again), and need read -access to the vault resource, as discovering it does. +On a vault, the right pane shows its settings: resource group, location, +SKU, whether access is by Azure roles or access policies, soft delete and +purge protection, which networks can reach it, private endpoints and tags. +They come with the list of vaults, so they cost no extra requests and are +cached with it. diff --git a/internal/provider/azure/azure.go b/internal/provider/azure/azure.go index b7b0629..f3fda6f 100644 --- a/internal/provider/azure/azure.go +++ b/internal/provider/azure/azure.go @@ -185,10 +185,6 @@ func (p *Provider) tenant(subscriptionID string) string { return p.tenants[subscriptionID] } -const vaultQuery = `resources -| where type =~ 'microsoft.keyvault/vaults' -| project id, name, uri = tostring(properties.vaultUri)` - func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) { var out []provider.Container req := armresourcegraph.QueryRequest{ @@ -228,7 +224,7 @@ func graphVault(src provider.Source, row any) (provider.Container, bool) { if id == "" || name == "" || uri == "" { return provider.Container{}, false } - return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true + return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes, Attrs: graphInfo(m).attrs()}, true } func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) { @@ -249,7 +245,7 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid } out = append(out, provider.Container{ Source: src, ID: deref(v.ID), Name: deref(v.Name), - Detail: *v.Properties.VaultURI, Types: objectTypes, + Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(), }) } } diff --git a/internal/provider/azure/azure_test.go b/internal/provider/azure/azure_test.go index fc21f3c..e9497cd 100644 --- a/internal/provider/azure/azure_test.go +++ b/internal/provider/azure/azure_test.go @@ -419,37 +419,48 @@ func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) { return b[1 : 1+size], b[1+size:] } -func TestVaultDetails(t *testing.T) { - id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv" - p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != id { - http.NotFound(w, r) - return - } - writeJSON(w, 200, map[string]any{"id": id, "name": "kv", "location": "norwayeast", - "tags": map[string]any{"team": "platform", "env": "prod"}, - "properties": map[string]any{ - "tenantId": "t", "sku": map[string]any{"family": "A", "name": "premium"}, - "enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30, - "networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}}, - "privateEndpointConnections": []any{map[string]any{"id": "pe1"}}, - }}) - }) - attrs, err := p.Details(context.Background(), provider.Container{ID: id}) - if err != nil { - t.Fatal(err) - } - var got []string - for _, a := range attrs { - got = append(got, a.Label+"="+a.Value) - } +func TestVaultSettings(t *testing.T) { want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)", "soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)", "private=1 endpoint", "tags=env: prod", "=team: platform"} - if !slices.Equal(got, want) { - t.Errorf("details\n got %q\nwant %q", got, want) + check := func(name string, attrs []provider.Attr) { + t.Helper() + var got []string + for _, a := range attrs { + got = append(got, a.Label+"="+a.Value) + } + if !slices.Equal(got, want) { + t.Errorf("%s\n got %q\nwant %q", name, got, want) + } } - if _, err := p.Details(context.Background(), provider.Container{ID: "https://kv.vault.azure.net/"}); !errors.Is(err, provider.ErrUnsupported) { - t.Errorf("pinned vault: %v", err) + + // Resource Graph: the query's columns, as JSON gives them. + c, ok := graphVault(provider.Source{ID: "s"}, map[string]any{ + "id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/", "location": "norwayeast", + "resourceGroup": "rg-sec", "tags": map[string]any{"team": "platform", "env": "prod"}, + "sku": "premium", "rbac": true, "softDelete": nil, "retention": 30.0, "purge": true, + "publicAccess": "", "defaultAction": "Deny", "ipRules": 1.0, "subnets": 0.0, "endpoints": 1.0, "policies": 0.0, + }) + if !ok { + t.Fatal("row not read") } + check("resource graph", c.Attrs) + + // The management API, when Resource Graph fails. + id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv" + p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": id, "name": "kv", "location": "norwayeast", + "tags": map[string]any{"team": "platform", "env": "prod"}, + "properties": map[string]any{ + "tenantId": "t", "vaultUri": "https://kv.vault.azure.net/", "sku": map[string]any{"family": "A", "name": "premium"}, + "enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30, + "networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}}, + "privateEndpointConnections": []any{map[string]any{"id": "pe1"}}, + }}}}) + }) + vaults, err := p.armVaults(context.Background(), provider.Source{ID: "s"}) + if err != nil || len(vaults) != 1 { + t.Fatalf("arm: %v %v", vaults, err) + } + check("management API", vaults[0].Attrs) } diff --git a/internal/provider/azure/details.go b/internal/provider/azure/details.go index 89cb630..714005c 100644 --- a/internal/provider/azure/details.go +++ b/internal/provider/azure/details.go @@ -1,7 +1,6 @@ package azure import ( - "context" "fmt" "slices" "strings" @@ -12,66 +11,131 @@ import ( "git.kebcloud.no/keb/lazysecrets/internal/provider" ) -// Details are a vault's settings, from the Key Vault management API: where -// it is, how access to it works, and how it's protected. Pinned vaults, -// known only by their URL, have none. -func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) { - id, err := arm.ParseResourceID(c.ID) - if err != nil { - return nil, provider.ErrUnsupported - } - client, err := armkeyvault.NewVaultsClient(id.SubscriptionID, p.cred, p.opts.arm) - if err != nil { - return nil, err - } - resp, err := client.Get(ctx, id.ResourceGroupName, id.Name, nil) - if err != nil { - return nil, cleanError(err) - } - return vaultAttrs(resp.Vault), nil +// A vault's settings come with the listing, from Resource Graph or the +// Key Vault management API, with no requests of their own: where it is, +// how access to it works, and how it's protected. + +type vaultInfo struct { + group, location, sku string + rbac, softDelete, purge *bool + retention *int32 + publicAccess, defaultAction string + ipRules, subnets, endpoints int + policies int + tags map[string]string } -func vaultAttrs(v armkeyvault.Vault) []provider.Attr { +// vaultQuery lists vaults with just the settings shown, not their whole +// properties (access policies can be long). +const vaultQuery = `resources +| where type =~ 'microsoft.keyvault/vaults' +| project id, name, uri = tostring(properties.vaultUri), location, resourceGroup, tags, + sku = tostring(properties.sku.name), rbac = properties.enableRbacAuthorization, + softDelete = properties.enableSoftDelete, retention = properties.softDeleteRetentionInDays, + purge = properties.enablePurgeProtection, publicAccess = tostring(properties.publicNetworkAccess), + defaultAction = tostring(properties.networkAcls.defaultAction), + ipRules = array_length(properties.networkAcls.ipRules), + subnets = array_length(properties.networkAcls.virtualNetworkRules), + endpoints = array_length(properties.privateEndpointConnections), + policies = array_length(properties.accessPolicies)` + +// graphInfo reads a vaultQuery row's settings. +func graphInfo(m map[string]any) vaultInfo { + str := func(k string) string { s, _ := m[k].(string); return s } + num := func(k string) int { f, _ := m[k].(float64); return int(f) } + flag := func(k string) *bool { + if b, ok := m[k].(bool); ok { + return &b + } + return nil + } + v := vaultInfo{ + group: str("resourceGroup"), location: str("location"), sku: str("sku"), + rbac: flag("rbac"), softDelete: flag("softDelete"), purge: flag("purge"), + publicAccess: str("publicAccess"), defaultAction: str("defaultAction"), + ipRules: num("ipRules"), subnets: num("subnets"), endpoints: num("endpoints"), policies: num("policies"), + } + if f, ok := m["retention"].(float64); ok { + r := int32(f) + v.retention = &r + } + if tags, ok := m["tags"].(map[string]any); ok { + v.tags = map[string]string{} + for k, t := range tags { + v.tags[k] = fmt.Sprint(t) + } + } + return v +} + +// armInfo reads a vault's settings from the management API. +func armInfo(v *armkeyvault.Vault) vaultInfo { + info := vaultInfo{location: deref(v.Location), tags: map[string]string{}} + if id, err := arm.ParseResourceID(deref(v.ID)); err == nil { + info.group = id.ResourceGroupName + } + for k, t := range v.Tags { + info.tags[k] = deref(t) + } + pr := v.Properties + if pr == nil { + return info + } + if pr.SKU != nil && pr.SKU.Name != nil { + info.sku = string(*pr.SKU.Name) + } + info.rbac, info.softDelete, info.purge = pr.EnableRbacAuthorization, pr.EnableSoftDelete, pr.EnablePurgeProtection + info.retention, info.publicAccess = pr.SoftDeleteRetentionInDays, deref(pr.PublicNetworkAccess) + if acl := pr.NetworkACLs; acl != nil { + if acl.DefaultAction != nil { + info.defaultAction = string(*acl.DefaultAction) + } + info.ipRules, info.subnets = len(acl.IPRules), len(acl.VirtualNetworkRules) + } + info.endpoints, info.policies = len(pr.PrivateEndpointConnections), len(pr.AccessPolicies) + return info +} + +func (v vaultInfo) attrs() []provider.Attr { var out []provider.Attr add := func(label, value string) { if value != "" { out = append(out, provider.Attr{Label: label, Value: value}) } } - if id, err := arm.ParseResourceID(deref(v.ID)); err == nil { - add("group", id.ResourceGroupName) - } - add("location", deref(v.Location)) - pr := v.Properties - if pr == nil { - return out - } - if pr.SKU != nil && pr.SKU.Name != nil { - add("SKU", string(*pr.SKU.Name)) - } + add("group", v.group) + add("location", v.location) + add("SKU", v.sku) // Who may read secrets is decided by Azure roles or by the vault's // own access policies, never both. - if on(pr.EnableRbacAuthorization, false) { + if on(v.rbac, false) { add("access", "Azure roles (RBAC)") } else { - add("access", fmt.Sprintf("access policies (%d)", len(pr.AccessPolicies))) + add("access", fmt.Sprintf("access policies (%d)", v.policies)) } - if on(pr.EnableSoftDelete, true) { + if on(v.softDelete, true) { days := int32(90) - if pr.SoftDeleteRetentionInDays != nil { - days = *pr.SoftDeleteRetentionInDays + if v.retention != nil { + days = *v.retention } add("soft delete", fmt.Sprintf("on, %d days", days)) } else { add("soft delete", "off") } - add("purge prot.", map[bool]string{true: "on", false: "off"}[on(pr.EnablePurgeProtection, false)]) - add("network", network(pr)) - if n := len(pr.PrivateEndpointConnections); n > 0 { - add("private", fmt.Sprintf("%d endpoint%s", n, map[bool]string{true: "s"}[n > 1])) + add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)]) + switch { + case strings.EqualFold(v.publicAccess, "disabled"): + add("network", "private endpoints only") + case strings.EqualFold(v.defaultAction, "deny"): + add("network", fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", v.ipRules, v.subnets)) + default: + add("network", "all networks") } - keys := make([]string, 0, len(v.Tags)) - for k := range v.Tags { + if v.endpoints > 0 { + add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1])) + } + keys := make([]string, 0, len(v.tags)) + for k := range v.tags { keys = append(keys, k) } slices.Sort(keys) @@ -80,23 +144,11 @@ func vaultAttrs(v armkeyvault.Vault) []provider.Attr { if i == 0 { label = "tags" } - out = append(out, provider.Attr{Label: label, Value: k + ": " + deref(v.Tags[k])}) + out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]}) } return out } -// network is where the vault can be reached from. -func network(pr *armkeyvault.VaultProperties) string { - if strings.EqualFold(deref(pr.PublicNetworkAccess), "disabled") { - return "private endpoints only" - } - acl := pr.NetworkACLs - if acl == nil || acl.DefaultAction == nil || *acl.DefaultAction != armkeyvault.NetworkRuleActionDeny { - return "all networks" - } - return fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", len(acl.IPRules), len(acl.VirtualNetworkRules)) -} - // on is *b, or def when it isn't set. func on(b *bool, def bool) bool { if b == nil { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 3affae6..4e7dfab 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -93,6 +93,8 @@ type Container struct { // secrets only. Types []ObjectType URL string // web page for the container, if any + // Attrs describe it further, from the listing (a vault's settings). + Attrs []Attr `json:",omitempty"` } // ObjectTypes returns the container's object types, defaulting to secrets. diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index f40c162..12f119d 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -187,6 +187,9 @@ func (m Model) containerFields(c provider.Container) []panes.Field { add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value))) } } + for _, a := range c.Attrs { + out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn}) + } switch st := m.details[containerKey(c)]; { case st.loading: out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true}) From cd35a8acf42c3bf58a743c1c8de5a5b93e938401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:43:38 +0200 Subject: [PATCH 3/3] fix: details load once the cursor rests, and moving on cancels them Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/app.go | 10 +++++++++- internal/ui/app/fields.go | 26 ++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 8063a18..cd4b8d4 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -56,6 +56,7 @@ const ( slotDescribe // extra metadata for the selected object slotCopy // fetch a value straight to the clipboard slotVersions // count the selected object's versions + slotDetails // a vault's, scope's or file's details slotCount ) @@ -591,7 +592,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err} case detailsMsg: - m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err} + if msg.req == m.reqID[slotDetails] { + m.done(slotDetails) + } + if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) { + delete(m.details, msg.key) // tried again when shown again + } else { + m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err} + } case crawlMsg: cmds = append(cmds, m.handleCrawl(msg)) diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index 12f119d..2e51e3a 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -30,7 +30,8 @@ type groupsMsg struct { } // A vault's, scope's or file's details beyond its listing load once a -// session, the first time it's shown, and again with refresh. +// session, when the cursor rests on it, and again with refresh. They're +// extra: moving on cancels the load, and search never waits for them. type detailsState struct { attrs []provider.Attr err error @@ -38,6 +39,7 @@ type detailsState struct { } type detailsMsg struct { + req int key string attrs []provider.Attr err error @@ -211,14 +213,22 @@ func (m *Model) loadDetails(c provider.Container) tea.Cmd { if _, seen := m.details[key]; !ok || seen { return nil } - m.details[key] = detailsState{loading: true} - timeout := m.timeout - return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - attrs, err := d.Details(ctx, c) - return detailsMsg{key: key, attrs: attrs, err: err} + // One at a time: the previous one, if it's still loading, is + // cancelled and forgotten, to load when it's shown again. + for k, st := range m.details { + if st.loading { + delete(m.details, k) + } } + m.details[key] = detailsState{loading: true} + ctx, req := m.begin(slotDetails) + return m.settled(ctx, func() tea.Msg { + attrs, err := d.Details(ctx, c) + if ctx.Err() != nil { + err = ctx.Err() + } + return detailsMsg{req: req, key: key, attrs: attrs, err: err} + }) } // problemFields show why an account has nothing to show, wrapped to the