From 4d6db97d54e8dd7365047b6442bb2a28545ca750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:35:11 +0200 Subject: [PATCH 01/13] feat: a list from the cache says how old it is while it reloads Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/config.md | 5 +++-- internal/ui/app/app.go | 24 ++++++++++++++++++++++++ internal/ui/app/persist_test.go | 26 ++++++++++++++++++++++++++ internal/ui/panes/list.go | 7 +++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index 5fc5344..c44a577 100644 --- a/docs/config.md +++ b/docs/config.md @@ -114,8 +114,9 @@ What lazysecrets has loaded is kept in you: the Sources lists, vaults and scopes, and secrets' names and metadata (tags, dates, references), never values. On the next start they show at once, and anything older than two minutes reloads in the background when -you go to it, or when Go to searches it. Revealing and copying always -fetch the value live. +you go to it, or when Go to searches it. While it does, the pane's +bottom border says how old what it shows is (`cached 3h ago`). Revealing +and copying always fetch the value live. Lists not loaded for 30 days are dropped, and so is everything from an account you're no longer signed in to. Repos aren't cached: their files diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 7b0880c..b4b68cb 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -456,6 +456,8 @@ func New(providers []provider.Provider, opts Options) Model { m.loadCacheFile() if len(m.sourceResults) > 0 { m.rebuildSources() + m.sources.Loading = true // reloading what the file had + m.sources.Age = m.cachedAge(m.oldestSources()) } } if m.placeDir != "" && opts.Open == nil { @@ -1154,6 +1156,7 @@ func (m *Model) showContainers(src provider.Source, key string) tea.Cmd { return nil } m.containers.Refresh() + m.containers.Age = m.cachedAge(e.at) return m.loadContainers(src, key) } @@ -1170,6 +1173,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd { return nil } m.secrets.Refresh() + m.secrets.Age = m.cachedAge(e.at) return m.loadObjects(c, m.objType, key) } @@ -1299,6 +1303,26 @@ func (m *Model) detailStale() bool { return hasSecret || shown != m.containers.SelectedID() } +// cachedAge is how old a list loaded at at is, if it's older than +// cacheTTL: the panes say it while it reloads. +func (m Model) cachedAge(at time.Time) string { + if d := m.now().Sub(at); d >= cacheTTL { + return ago(d) + } + return "" +} + +// oldestSources is when the oldest of the cached Sources lists loaded. +func (m Model) oldestSources() time.Time { + var oldest time.Time + for _, at := range m.sourcesAt { + if oldest.IsZero() || at.Before(oldest) { + oldest = at + } + } + return oldest +} + func (m *Model) handleSources(msg sourcesMsg) tea.Cmd { if msg.gen != m.sourcesGen { return nil diff --git a/internal/ui/app/persist_test.go b/internal/ui/app/persist_test.go index 26cc9dc..e1c1b6c 100644 --- a/internal/ui/app/persist_test.go +++ b/internal/ui/app/persist_test.go @@ -39,6 +39,9 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) { // Next run, with a backend too slow to answer during the test: the // lists show from the file, and the cursor is where it was. d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute)) + if strings.Contains(d2.screen(), "cached") { + t.Errorf("a fresh cache said its age:\n%s", d2.screen()) + } d2.keys("2") if _, v, _ := d2.selected(); v != vault { t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen()) @@ -49,6 +52,29 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) { } } +// Lists from an older cache say how old they are while they reload. +func TestCacheFileSaysItsAge(t *testing.T) { + t.Parallel() + file := filepath.Join(t.TempDir(), "cache.json") + d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0)) + d.keys("3") + at := time.Now().Add(-3 * time.Hour) + for k, e := range d.m.cache.containers { + d.m.cache.containers[k] = entry[[]provider.Container]{e.value, at} + } + for id := range d.m.sourcesAt { + d.m.sourcesAt[id] = at + } + if err := d.m.Persist(); err != nil { + t.Fatal(err) + } + d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute)) + d2.keys("2") + if n := strings.Count(d2.screen(), "cached 3h ago"); n != 2 { + t.Errorf("Sources and Vaults should say they're from 3h ago (%d do):\n%s", n, d2.screen()) + } +} + func TestCacheFileDropsOldAndSignedOut(t *testing.T) { t.Parallel() file := filepath.Join(t.TempDir(), "cache.json") diff --git a/internal/ui/panes/list.go b/internal/ui/panes/list.go index 21252b2..aa685fd 100644 --- a/internal/ui/panes/list.go +++ b/internal/ui/panes/list.go @@ -34,6 +34,9 @@ type List[T any] struct { Empty string // shown when loaded with no items Loading bool Err error + // Age says how old the items are ("2h ago") while they reload, by + // the counter. + Age string item Item[T] items []T @@ -88,6 +91,7 @@ func (l *List[T]) SetError(err error) { func (l *List[T]) Refresh() { l.Loading = true l.Err = nil + l.Age = "" } func (l *List[T]) SetLoading() { @@ -369,6 +373,9 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string { if len(l.visible) > 0 { counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible)) } + if l.Loading && l.Age != "" && len(l.items) > 0 { + counter = "cached " + l.Age + " · " + counter + } return BoxCounted(st, title, "", counter, body, l.w, l.h, focused) } -- 2.54.0 From 164d933bea5e83adddce4364f1d1147fda7bf73a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:37:21 +0200 Subject: [PATCH 02/13] feat: with nothing to show, the right pane says how to get started Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 3 +- internal/ui/app/app_test.go | 33 ++++++++++++++++++ internal/ui/app/fields.go | 6 ++++ internal/ui/app/getstarted.go | 66 +++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 internal/ui/app/getstarted.go diff --git a/README.md b/README.md index d01adc4..c587ae6 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ lazysecrets # start lazysecrets lazysecrets --demo # test with demo data ``` -Pick a source on the left, then a vault, then a secret. The keys you'll +Pick a source on the left, then a vault, then a secret. With nothing +signed in yet, the right pane says how to get started. The keys you'll use most: | Key | Action | diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index e526a91..d461549 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1236,3 +1236,36 @@ func TestStatusPane(t *testing.T) { t.Errorf("back to %v", d.m.focus) } } + +// empty is an account with nothing in it, like Databricks without profiles. +type empty struct{ provider.Provider } + +func (empty) Sources(context.Context) ([]provider.Source, error) { return nil, nil } + +func TestGetStarted(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{}, signedOut{fake.Azure(0)}, empty{fake.Databricks(0)}) + scr := d.screen() + for _, want := range []string{"get started", "Azure az login", "L runs it here", + "databricks auth login --host", "lazysecrets --demo"} { + if !strings.Contains(scr, want) { + t.Errorf("missing %q:\n%s", want, scr) + } + } + d.keys("l", "j") + if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" { + t.Errorf("L signs in to %+v", a) + } + d.keys("y") + if got := d.clip().content; got != "az login" { + t.Errorf("y copied %q", got) + } + + // Once an account has something, an empty one says what it needs. + d = newDriverOpts(t, Options{}, fake.Azure(0), empty{fake.Databricks(0)}) + d.m.sources.SelectWhere(func(r sourceRow) bool { return r.kind == rowGroup && r.provider == fake.Databricks(0).ID() }) + d.send(nil) + if scr := d.screen(); strings.Contains(scr, "get started") || !strings.Contains(scr, "databricks auth login") { + t.Errorf("empty Databricks:\n%s", scr) + } +} diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index 6a0f253..16574f7 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -53,6 +53,8 @@ func (m *Model) syncFields() tea.Cmd { case paneSources: r, ok := m.sources.Selected() switch { + case m.nothingFound(): + m.fields.Show("get started", m.getStartedFields()) case !ok: m.fields.Show("", nil) case m.selectedProblem() != "": @@ -106,6 +108,10 @@ func (m *Model) showAccount(r sourceRow) tea.Cmd { } fields = append(fields, groupFields(st)...) } + if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 { + hint := emptyHint(p.Kind()) + fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL}) + } m.fields.Show(p.Label(), fields) return cmd } diff --git a/internal/ui/app/getstarted.go b/internal/ui/app/getstarted.go new file mode 100644 index 0000000..382cd5b --- /dev/null +++ b/internal/ui/app/getstarted.go @@ -0,0 +1,66 @@ +package app + +import ( + "errors" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" + "git.kebcloud.no/keb/lazysecrets/internal/update" +) + +// When no account has anything to show, as on a first run, the right +// pane says how to get started: the sign-in for each kind of account +// (y copies it, L runs az login), and the demo. + +// nothingFound reports whether every account has loaded, with nothing. +func (m Model) nothingFound() bool { + if m.sourcesPending > 0 { + return false + } + for _, p := range m.providers { + if len(m.sourceResults[p.ID()].sources) > 0 { + return false + } + } + return true +} + +// getStartedFields are the rows saying how to get started. +func (m Model) getStartedFields() []panes.Field { + out := []panes.Field{{Value: "Nothing to show yet: sign in where your secrets are.", Info: true}} + seen := map[provider.Kind]bool{} + for _, p := range m.providers { + if seen[p.Kind()] { + continue + } + seen[p.Kind()] = true + f := emptyHint(p.Kind()) + var auth *provider.AuthError + switch err := m.sourceResults[p.ID()].err; { + case errors.As(err, &auth) && len(auth.Login) > 0: + f.Provider = p.ID() // L signs in + out = append(out, f, panes.Field{Value: withKey(m.keys.Login, "%s runs it here"), Info: true}) + case err != nil: + out = append(out, f, panes.Field{Value: err.Error(), Info: true, Err: true}) + default: + out = append(out, f) + } + } + return append(out, + panes.Field{Label: "try", Value: "lazysecrets --demo"}, + panes.Field{Value: "built-in sample data, to look around", Info: true}, + panes.Field{Label: "docs", Value: "getting started", URL: update.DefaultRepo + "#get-started"}, + ) +} + +// emptyHint is how an account of kind gets something to show. +func emptyHint(kind provider.Kind) panes.Field { + switch kind { + case provider.KindDatabricks: + return panes.Field{Label: "Databricks", Value: "databricks auth login --host https://"} + case provider.KindRepo: + return panes.Field{Label: "repos", Value: "SOPS and .env files in your git repos", + URL: update.DefaultRepo + "/src/branch/main/docs/repos.md"} + } + return panes.Field{Label: string(kind), Value: "az login"} +} -- 2.54.0 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 03/13] 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 { -- 2.54.0 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 04/13] 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}) -- 2.54.0 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 05/13] 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 -- 2.54.0 From 365cb5b1df12468eda81e7661873b244a3d60f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:50:43 +0200 Subject: [PATCH 06/13] feat: `l` goes to the details of what's selected; `enter` still drills in Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 3 ++- docs/keys.md | 8 ++++---- internal/ui/app/app.go | 2 ++ internal/ui/app/app_test.go | 4 +++- internal/ui/keys/keys.go | 7 ++++--- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d01adc4..3d5574d 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ use most: | Key | Action | |---|---| -| `j`/`k`, `h`/`l`, `enter`, `esc` | move, switch panes, drill in, back | +| `j`/`k`, `enter`, `esc` | move, drill in, back | +| `l` / `h` | the details of what's selected / back | | `/` | filter the list you're in | | `space` | reveal / hide the value | | `y` | copy the value (cleared from the clipboard after 30s) | diff --git a/docs/keys.md b/docs/keys.md index 893d179..d087f15 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -13,7 +13,8 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter |---|---| | `j`/`k`, `↑`/`↓` | move | | `g`/`G` | top / bottom | -| `tab`/`l`, `shift+tab`/`h` | next / previous pane | +| `tab`, `shift+tab`/`h` | next / previous pane | +| `l` | the details of what's selected, on the right (`h` goes back) | | `1`–`4` | jump to pane | | `enter` / `esc`, `⌫` | drill in / back (also clears a filter or leaves the versions view) | | `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets | @@ -107,9 +108,8 @@ anything loads. The pane on the right describes what's selected in the pane you're in: the account or subscription in Sources, the vault in Vaults, the secret in -Secrets. Going into it (`4`, or `tab` from an account's row) keeps it on -that; there `j`/`k`, `/` and `y` work on its rows, and `shift+tab` goes -back. A value you reveal stays in view whichever pane you're in, until +Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`, +`/` and `y` work on its rows, and `h` goes back. A value you reveal stays in view whichever pane you're in, until it's hidden. On top, the status pane (`0`) shows the version, a newer release and how diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 7b0880c..362f185 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -821,6 +821,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) { if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup { m.focus = paneDetail // an account's row: nothing between } + case key.Matches(msg, k.Details): + m.focus = paneDetail // what's selected, on the right case key.Matches(msg, k.PrevPane): if m.focus == paneDetail { m.focus = m.lastLeft // back to what it describes diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index e526a91..3f1236d 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -758,7 +758,9 @@ func TestFocusKeys(t *testing.T) { }{ {"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail}, {"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers}, - {"4", paneDetail}, {"l", paneSources}, {"h", paneDetail}, + {"4", paneDetail}, {"tab", paneSources}, {"shift+tab", paneDetail}, + // l: what's selected, on the right; h: back to it. + {"2", paneContainers}, {"l", paneDetail}, {"h", paneContainers}, {"h", paneSources}, {"l", paneDetail}, } { d.keys(tc.key) if d.m.focus != tc.want { diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go index 1e81dc3..e3d7d6d 100644 --- a/internal/ui/keys/keys.go +++ b/internal/ui/keys/keys.go @@ -36,7 +36,7 @@ const ( type Map struct { // lists Up, Down, Top, Bottom key.Binding - NextPane, PrevPane key.Binding + NextPane, PrevPane, Details key.Binding Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding NextTab, PrevTab key.Binding Enter, Back, Filter key.Binding @@ -87,7 +87,8 @@ func (m *Map) Actions() []Action { a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."), a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."), a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."), - a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane."), + a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane; from the detail pane, the one it describes."), + a(Lists, "details", &m.Details, "details", "move", "Focus the detail pane on the right, on what's selected: a source, vault, file or secret."), a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."), a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."), a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."), @@ -178,7 +179,7 @@ func (m *Map) Actions() []Action { var defaults = map[string][]string{ "lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"}, "lists.top": {"g", "home"}, "lists.bottom": {"G", "end"}, - "lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"}, + "lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"}, "lists.enter": {"enter"}, "lists.back": {"esc", "backspace"}, "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, -- 2.54.0 From 8e8d1c5af07c0e11249439942d2f6b5495a467f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:53:22 +0200 Subject: [PATCH 07/13] fix: the merged tests' closing braces Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/app_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index 719ef07..9fffdcc 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1269,6 +1269,8 @@ func TestGetStarted(t *testing.T) { d.send(nil) if scr := d.screen(); strings.Contains(scr, "get started") || !strings.Contains(scr, "databricks auth login") { t.Errorf("empty Databricks:\n%s", scr) + } +} // detailed is an account whose vaults have details. type detailed struct{ provider.Provider } -- 2.54.0 From c9bc4c143138edc21b77290d835ff866452fd585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:55:52 +0200 Subject: [PATCH 08/13] feat: what's expiring, in the status pane and `lazysecrets expiring` Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 3 +- cmd/lazysecrets/expiring.go | 163 +++++++++++++++++++++++++++++++ cmd/lazysecrets/expiring_test.go | 41 ++++++++ cmd/lazysecrets/main.go | 19 +++- docs/azure.md | 20 ++++ internal/ui/app/app.go | 14 +++ internal/ui/app/app_test.go | 22 +++++ internal/ui/app/expiring.go | 75 ++++++++++++++ internal/ui/app/status.go | 5 + internal/ui/panes/fields.go | 5 +- 10 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 cmd/lazysecrets/expiring.go create mode 100644 cmd/lazysecrets/expiring_test.go create mode 100644 internal/ui/app/expiring.go diff --git a/README.md b/README.md index d01adc4..78e26bc 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti - **Favourites**: star the secrets you use most and get back to them in one key. - **Expiry warnings**: secrets, keys and certificates that expire soon are - marked. + marked, listed together in the status pane, and `lazysecrets expiring` + checks every vault from a pipeline. - **Your way**: rebind any key, pick a theme, and add custom commands to run on a secret. - **Try it first**: `lazysecrets --demo` runs on built-in sample data. diff --git a/cmd/lazysecrets/expiring.go b/cmd/lazysecrets/expiring.go new file mode 100644 index 0000000..7975ee0 --- /dev/null +++ b/cmd/lazysecrets/expiring.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "slices" + "strings" + "sync" + "text/tabwriter" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" +) + +// lazysecrets expiring lists what has expired, or will within --days, +// in every vault of every account: for a pipeline or a cron job. Expiry +// dates come with the lists, so it's one request per vault and type, +// a few at a time. Only Key Vault has expiry dates. + +// errExpiring makes the exit status 1 when anything was listed. +var errExpiring = errors.New("expiring") + +// listLimit is how many lists load at once. +const listLimit = 8 + +type expiringOpts struct { + days int + timeout time.Duration + now time.Time +} + +func parseExpiring(args []string, days int) (int, error) { + fs := flag.NewFlagSet("expiring", flag.ContinueOnError) + n := fs.Int("days", days, "list what expires within this many days (default: expiryWarningDays, else 30)") + if err := fs.Parse(args); err != nil { + return 0, err + } + if fs.NArg() > 0 || *n < 0 { + return 0, fmt.Errorf("usage: lazysecrets expiring [--days N]") + } + return *n, nil +} + +func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error { + var ( + mu sync.Mutex + found []provider.Object + seen = map[string]bool{} + failed int + vaults int + wg sync.WaitGroup + limit = make(chan struct{}, listLimit) + within = o.now.AddDate(0, 0, o.days) + warnErr = func(what string, err error) { + mu.Lock() + defer mu.Unlock() + failed++ + fmt.Fprintf(errs, "lazysecrets: %s: %v\n", what, err) + } + ) + run := func(f func(ctx context.Context)) { + wg.Add(1) + go func() { + defer wg.Done() + limit <- struct{}{} + defer func() { <-limit }() + ctx, cancel := context.WithTimeout(context.Background(), o.timeout) + defer cancel() + f(ctx) + }() + } + var vaultsDone sync.WaitGroup + for _, p := range providers { + if p.Kind() != provider.KindAzure { + continue + } + vaultsDone.Add(1) + run(func(ctx context.Context) { + defer vaultsDone.Done() + srcs, err := p.Sources(ctx) + if err != nil { + warnErr(p.Label(), err) + return + } + for _, src := range srcs { + vaultsDone.Add(1) + run(func(ctx context.Context) { + defer vaultsDone.Done() + cs, err := p.Containers(ctx, src) + if err != nil { + warnErr(src.Name, err) + return + } + for _, c := range cs { + mu.Lock() + fresh := !seen[c.ID] + seen[c.ID] = true + if fresh { + vaults++ + } + mu.Unlock() + if !fresh { // the same vault through another account + continue + } + for _, t := range c.ObjectTypes() { + run(func(ctx context.Context) { + objs, err := p.Objects(ctx, c, t) + if err != nil { + warnErr(c.Name+" "+strings.ToLower(string(t))+"s", err) + return + } + mu.Lock() + defer mu.Unlock() + for _, o := range objs { + if !o.Expires.IsZero() && o.Expires.Before(within) { + found = append(found, o) + } + } + }) + } + } + }) + } + }) + } + // Listing vaults queues the lists of their secrets, so wait for both. + vaultsDone.Wait() + wg.Wait() + + slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) }) + if len(found) == 0 { + fmt.Fprintf(errs, "nothing expires within %d days (%d vaults)\n", o.days, vaults) + if failed > 0 { + return fmt.Errorf("%d lists couldn't be read", failed) + } + return nil + } + tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "LEFT\tEXPIRES\tTYPE\tVAULT\tNAME\tLINK") + for _, x := range found { + left := "expired" + if d := x.Expires.Sub(o.now); d > 0 { + left = fmt.Sprintf("%dd", int(d.Hours()/24)) + } + link := "" + if r, err := ref.ForObject(x, ""); err == nil { + link = r.String() + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", left, x.Expires.Local().Format("2006-01-02"), + strings.ToLower(string(x.Type)), x.Container.Name, x.Name, link) + } + if err := tw.Flush(); err != nil { + return err + } + if failed > 0 { + fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: the list may be incomplete\n", failed) + } + return errExpiring +} diff --git a/cmd/lazysecrets/expiring_test.go b/cmd/lazysecrets/expiring_test.go new file mode 100644 index 0000000..24faa2b --- /dev/null +++ b/cmd/lazysecrets/expiring_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" +) + +func TestExpiring(t *testing.T) { + providers := []provider.Provider{fake.Azure(0), fake.Databricks(0)} + now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + var out, errs bytes.Buffer + err := expiring(providers, expiringOpts{days: 30, timeout: time.Minute, now: now}, &out, &errs) + if !errors.Is(err, errExpiring) { + t.Fatalf("err %v (want errExpiring: something listed)", err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 || !strings.HasPrefix(lines[1], "expired 2026-03-01 certificate kv-app-prod old-tls") || + !strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") { + t.Errorf("out:\n%s", out.String()) + } + if !strings.Contains(errs.String(), "kv-restricted") { + t.Errorf("unreadable vaults not said:\n%s", errs.String()) + } + + // Further ahead, the soonest first. + out.Reset() + expiring(providers, expiringOpts{days: 400, timeout: time.Minute, now: now}, &out, &errs) + if got := strings.Count(out.String(), "\n"); got != 4 || !strings.Contains(out.String(), "app-tls") { + t.Errorf("400 days:\n%s", out.String()) + } + + if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil { + t.Error("negative days accepted") + } +} diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 571de9e..110066f 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -4,6 +4,7 @@ package main import ( "context" + "errors" "flag" "fmt" "io" @@ -33,7 +34,9 @@ import ( ) func main() { - if err := run(); err != nil { + if err := run(); errors.Is(err, errExpiring) { + os.Exit(1) // listed: a pipeline fails on it + } else if err != nil { fmt.Fprintln(os.Stderr, "lazysecrets:", err) os.Exit(1) } @@ -118,6 +121,9 @@ func run() error { @Microsoft.KeyVault(...) or {{secrets/scope/key}} lazysecrets [flags] get print the value a lazysecrets:// link points at, for scripts: VAR=$(lazysecrets get ) + lazysecrets [flags] expiring [--days N] + list what has expired or will within N days + (default 30) in every vault; exits 1 if any lazysecrets [flags] keybindings print every action and its keys (with your config applied), as config to copy and edit lazysecrets handler install [--terminal NAME] @@ -140,6 +146,7 @@ Flags: } var open, getRef *ref.Ref + var expiringArgs []string printKeys := false switch args := flag.Args(); { case len(args) == 1 && args[0] == "keybindings": @@ -150,6 +157,8 @@ Flags: return err } getRef = &r + case len(args) > 0 && args[0] == "expiring": + expiringArgs = append([]string{}, args[1:]...) case len(args) > 0 && args[0] == "handler": return handler(*configPath, args[1:]) case len(args) > 0 && args[0] == "launch": @@ -298,6 +307,14 @@ Flags: // the standard logger, which would draw over the TUI. log.SetOutput(io.Discard) + if expiringArgs != nil { + days, err := parseExpiring(expiringArgs, expiryDays) + if err != nil { + return err + } + return expiring(providers, expiringOpts{days: days, timeout: *timeout, now: time.Now()}, os.Stdout, os.Stderr) + } + if getRef != nil { ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() diff --git a/docs/azure.md b/docs/azure.md index 85a9e91..82c157a 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -64,3 +64,23 @@ 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 `✗`. + +The status pane (`0`) lists them across every vault loaded, opened or +cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` +on one goes to it. That makes no requests of its own: expiry dates come +with the lists. It says how many vaults it covers; `ctrl+a` in search +loads the rest. + +For a pipeline or a cron job, `lazysecrets expiring` lists them in every +vault of every account, loading each vault's lists (a few at a time), and +exits 1 when it lists anything: + +```sh +$ lazysecrets expiring --days 30 +LEFT EXPIRES TYPE VAULT NAME LINK +expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls +12d 2026-10-23 secret kv-app-prod api-key lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key +``` + +`--days` defaults to `expiryWarningDays`. Lists it can't read are named on +stderr. diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 7b0880c..06e247e 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -841,6 +841,12 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) { m.focus = paneDetail // its rows are on the right case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup(): // folded or unfolded a provider group + case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "": + r, err := ref.Parse(m.fieldLink()) + if err == nil { + f, _ := m.fields.Selected() + m.startNav(r, actGo, f.Row) + } case key.Matches(msg, k.Enter): if m.focus < paneDetail { m.focus++ @@ -1738,6 +1744,14 @@ func (m Model) problemText(providerID, err string) string { return err } +// fieldLink is the link of the selected Fields row, if it has one. +func (m Model) fieldLink() string { + if f, ok := m.fields.Selected(); ok && m.fields.Shown() { + return f.Link + } + return "" +} + // loginFor is the sign-in L runs: the selected account's, if it needs // one, else the last one asked for. func (m Model) loginFor() *provider.AuthError { diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index e526a91..08ab570 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1236,3 +1236,25 @@ func TestStatusPane(t *testing.T) { t.Errorf("back to %v", d.m.focus) } } + +func TestExpiringInStatus(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0)) + d.keys("3", "]", "]") // load kv-app-prod's certificates + if !strings.Contains(d.screen(), "⚠ 1 expiring") { + t.Errorf("status line:\n%s", d.screen()) + } + d.keys("0") + scr := d.screen() + for _, want := range []string{"expiring ✗ expired old-tls · kv-app-prod", "in 1 of 3 vaults (ctrl+a in search loads all)"} { + if !strings.Contains(scr, want) { + t.Errorf("missing %q:\n%s", want, scr) + } + } + d.keys("3", "[", "[", "0", "enter") // elsewhere, then to it from the status pane + d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "expiring" }) + d.keys("enter") + if _, v, o := d.selected(); v != "kv-app-prod" || o != "old-tls" { + t.Errorf("enter went to %s/%s\n%s", v, o, d.screen()) + } +} diff --git a/internal/ui/app/expiring.go b/internal/ui/app/expiring.go new file mode 100644 index 0000000..c2315a7 --- /dev/null +++ b/internal/ui/app/expiring.go @@ -0,0 +1,75 @@ +package app + +import ( + "cmp" + "fmt" + "slices" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// The status pane lists what has expired or will soon, across every vault +// loaded (opened, searched, or from the cache): expiry dates come with +// the lists, so this makes no requests. enter on one goes to it. + +type expiringItem struct { + o provider.Object + row string // its source's row key, to go to it +} + +// expiringItems are the loaded objects that have expired or will within +// the warning period, soonest first. +func (m Model) expiringItems() []expiringItem { + var out []expiringItem + for _, p := range m.providers { + for _, src := range m.sourceResults[p.ID()].sources { + row := sourceRowKey(p.ID(), src.ID) + for _, c := range m.cache.containers[row].value { + for _, t := range c.ObjectTypes() { + for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value { + if state, _ := m.expiry(o); state != notExpiring { + out = append(out, expiringItem{o, row}) + } + } + } + } + } + } + slices.SortFunc(out, func(a, b expiringItem) int { + return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name)) + }) + return out +} + +// expiringFields are the status pane's rows for them, and how much of +// everything they cover. +func (m Model) expiringFields() []panes.Field { + items := m.expiringItems() + var out []panes.Field + for _, it := range items { + state, left := m.expiry(it.o) + mark := "⚠ " + daysLeft(left) + if state == expired { + mark = "✗ expired" + } + f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row} + if r, err := ref.ForObject(it.o, ""); err == nil { + f.Link = r.String() + } + out = append(out, f) + } + cov := m.coverageOf(provider.KindAzure) + switch { + case cov.vaults == 0 && len(items) == 0: + return nil + case !cov.complete(): + out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) + + withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true}) + case len(items) == 0: + out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true}) + } + out[0].Label = "expiring" + return out +} diff --git a/internal/ui/app/status.go b/internal/ui/app/status.go index 02628fa..c3da3e5 100644 --- a/internal/ui/app/status.go +++ b/internal/ui/app/status.go @@ -38,6 +38,9 @@ func (m Model) statusLine() string { failing++ } } + if n := len(m.expiringItems()); n > 0 { + parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n))) + } if accounts > 0 { s := plural(accounts, "account") if failing > 0 { @@ -88,6 +91,8 @@ func (m Model) statusFields() []panes.Field { add(f) } + out = append(out, m.expiringFields()...) + for i, path := range m.configFiles { label := "" if i == 0 { diff --git a/internal/ui/panes/fields.go b/internal/ui/panes/fields.go index 7dd65b6..8b7780d 100644 --- a/internal/ui/panes/fields.go +++ b/internal/ui/panes/fields.go @@ -20,12 +20,15 @@ type Fields struct { // Field is a row: a value under a label. Info rows (loading, errors) // can't be copied; Err ones are errors. A row can also be opened: URL in -// the browser (o), Path in the editor (e), or Provider signed in to (L). +// the browser (o), Path in the editor (e), Provider signed in to (L), or +// Link gone to (enter). type Field struct { Label, Value string Info, Err bool URL, Path, Provider string + // Link is a lazysecrets link enter goes to, in the source of row Row. + Link, Row string } func NewFields() Fields { -- 2.54.0 From bf374176df270397fa2ff788e83ce9097ff5fcf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:57:46 +0200 Subject: [PATCH 09/13] fix: lazysecrets expiring says each problem once, and exits like grep Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/lazysecrets/expiring.go | 63 +++++++++++++++++++++++--------- cmd/lazysecrets/expiring_test.go | 9 ++++- cmd/lazysecrets/main.go | 13 +++++-- docs/azure.md | 9 +++-- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/cmd/lazysecrets/expiring.go b/cmd/lazysecrets/expiring.go index 7975ee0..49dc5c2 100644 --- a/cmd/lazysecrets/expiring.go +++ b/cmd/lazysecrets/expiring.go @@ -1,11 +1,13 @@ package main import ( + "cmp" "context" "errors" "flag" "fmt" "io" + "maps" "slices" "strings" "sync" @@ -21,8 +23,12 @@ import ( // dates come with the lists, so it's one request per vault and type, // a few at a time. Only Key Vault has expiry dates. -// errExpiring makes the exit status 1 when anything was listed. -var errExpiring = errors.New("expiring") +// The exit status is grep's: 1 when anything was listed (errExpiring), +// else 2 when some lists couldn't be read (errIncomplete), else 0. +var ( + errExpiring = errors.New("expiring") + errIncomplete = errors.New("incomplete") +) // listLimit is how many lists load at once. const listLimit = 8 @@ -47,19 +53,22 @@ func parseExpiring(args []string, days int) (int, error) { func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error { var ( - mu sync.Mutex - found []provider.Object - seen = map[string]bool{} - failed int - vaults int - wg sync.WaitGroup - limit = make(chan struct{}, listLimit) - within = o.now.AddDate(0, 0, o.days) + mu sync.Mutex + found []provider.Object + seen = map[string]bool{} + failed int + vaults int + wg sync.WaitGroup + limit = make(chan struct{}, listLimit) + within = o.now.AddDate(0, 0, o.days) + // A vault's lists that couldn't be read, said once per vault. + denied = map[string][]string{} + why = map[string]string{} warnErr = func(what string, err error) { mu.Lock() defer mu.Unlock() failed++ - fmt.Fprintf(errs, "lazysecrets: %s: %v\n", what, err) + fmt.Fprintf(errs, "lazysecrets: %s: %s\n", what, briefly(err)) } ) run := func(f func(ctx context.Context)) { @@ -109,12 +118,14 @@ func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer for _, t := range c.ObjectTypes() { run(func(ctx context.Context) { objs, err := p.Objects(ctx, c, t) - if err != nil { - warnErr(c.Name+" "+strings.ToLower(string(t))+"s", err) - return - } mu.Lock() defer mu.Unlock() + if err != nil { + failed++ + denied[c.Name] = append(denied[c.Name], strings.ToLower(string(t))+"s") + why[c.Name] = cmp.Or(why[c.Name], briefly(err)) + return + } for _, o := range objs { if !o.Expires.IsZero() && o.Expires.Before(within) { found = append(found, o) @@ -130,12 +141,20 @@ func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer // Listing vaults queues the lists of their secrets, so wait for both. vaultsDone.Wait() wg.Wait() + for _, name := range slices.Sorted(maps.Keys(denied)) { + fmt.Fprintf(errs, "lazysecrets: %s: can't list %s: %s\n", name, strings.Join(denied[name], ", "), why[name]) + } slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) }) if len(found) == 0 { - fmt.Fprintf(errs, "nothing expires within %d days (%d vaults)\n", o.days, vaults) + vault := "vaults" + if vaults == 1 { + vault = "vault" + } + fmt.Fprintf(errs, "nothing expires within %d days (checked %d of %d %s)\n", o.days, vaults-len(denied), vaults, vault) if failed > 0 { - return fmt.Errorf("%d lists couldn't be read", failed) + fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: they weren't checked\n", failed) + return errIncomplete } return nil } @@ -161,3 +180,13 @@ func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer } return errExpiring } + +// briefly is err on one line: for a sign-in, the command that signs in. +func briefly(err error) string { + var auth *provider.AuthError + if errors.As(err, &auth) && len(auth.Login) > 0 { + return "sign-in required: " + strings.Join(auth.Login, " ") + } + line, _, _ := strings.Cut(err.Error(), "\n") + return line +} diff --git a/cmd/lazysecrets/expiring_test.go b/cmd/lazysecrets/expiring_test.go index 24faa2b..924e5db 100644 --- a/cmd/lazysecrets/expiring_test.go +++ b/cmd/lazysecrets/expiring_test.go @@ -24,7 +24,7 @@ func TestExpiring(t *testing.T) { !strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") { t.Errorf("out:\n%s", out.String()) } - if !strings.Contains(errs.String(), "kv-restricted") { + if !strings.Contains(errs.String(), "kv-restricted: can't list ") || strings.Count(errs.String(), "kv-restricted") != 1 { t.Errorf("unreadable vaults not said:\n%s", errs.String()) } @@ -35,6 +35,13 @@ func TestExpiring(t *testing.T) { t.Errorf("400 days:\n%s", out.String()) } + // Nothing listed, but not everything checked. + errs.Reset() + err = expiring(providers, expiringOpts{days: 0, timeout: time.Minute, now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}, &out, &errs) + if !errors.Is(err, errIncomplete) { + t.Errorf("err %v, want errIncomplete", err) + } + if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil { t.Error("negative days accepted") } diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 110066f..79f5584 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -34,9 +34,12 @@ import ( ) func main() { - if err := run(); errors.Is(err, errExpiring) { + switch err := run(); { + case errors.Is(err, errExpiring): os.Exit(1) // listed: a pipeline fails on it - } else if err != nil { + case errors.Is(err, errIncomplete): + os.Exit(2) + case err != nil: fmt.Fprintln(os.Stderr, "lazysecrets:", err) os.Exit(1) } @@ -123,7 +126,8 @@ func run() error { for scripts: VAR=$(lazysecrets get ) lazysecrets [flags] expiring [--days N] list what has expired or will within N days - (default 30) in every vault; exits 1 if any + (default 30) in every vault; exits 1 if any, + 2 if it couldn't check everything lazysecrets [flags] keybindings print every action and its keys (with your config applied), as config to copy and edit lazysecrets handler install [--terminal NAME] @@ -310,7 +314,8 @@ Flags: if expiringArgs != nil { days, err := parseExpiring(expiringArgs, expiryDays) if err != nil { - return err + fmt.Fprintln(os.Stderr, "lazysecrets:", err) + return errIncomplete // 2, not 1: nothing was checked } return expiring(providers, expiringOpts{days: days, timeout: *timeout, now: time.Now()}, os.Stdout, os.Stderr) } diff --git a/docs/azure.md b/docs/azure.md index 94df010..753654d 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -72,8 +72,7 @@ with the lists. It says how many vaults it covers; `ctrl+a` in search loads the rest. For a pipeline or a cron job, `lazysecrets expiring` lists them in every -vault of every account, loading each vault's lists (a few at a time), and -exits 1 when it lists anything: +vault of every account, loading each vault's lists (a few at a time): ```sh $ lazysecrets expiring --days 30 @@ -82,8 +81,10 @@ expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-a 12d 2026-10-23 secret kv-app-prod api-key lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key ``` -`--days` defaults to `expiryWarningDays`. Lists it can't read are named on -stderr. +`--days` defaults to `expiryWarningDays`. Accounts that need a sign-in and +lists it can't read are named on stderr, one line each. Like grep, it +exits 0 when nothing expires, 1 when it lists something, and 2 when it +couldn't check everything. 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 -- 2.54.0 From 206e26fdba591f2d1695dddd8c74c5ef09686655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 14:04:14 +0200 Subject: [PATCH 10/13] feat: when a vault refuses, what to check: PIM, the owner, or the network Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/azure.md | 6 ++++-- internal/provider/azure/azure_test.go | 14 ++++++++++++-- internal/provider/azure/convert.go | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/azure.md b/docs/azure.md index 753654d..2686d85 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -40,8 +40,10 @@ vault resources (e.g. *Reader*). Key Vault data-plane access is separate | read secret values | *Key Vault Secrets User* | | read certificate details | *Key Vault Certificate User* | -A missing permission is shown in the pane, including the action that was -denied. +A missing permission is shown in the pane, shortened to what was denied +(the action, or the access policy permission), with what to check: a +role or group to activate in PIM, access from the vault's owner, or, when +its firewall refused, the network you're on. ## Vaults you can't discover diff --git a/internal/provider/azure/azure_test.go b/internal/provider/azure/azure_test.go index e9497cd..62f783e 100644 --- a/internal/provider/azure/azure_test.go +++ b/internal/provider/azure/azure_test.go @@ -175,8 +175,11 @@ func TestForbiddenIsShortened(t *testing.T) { if err == nil { t.Fatal("expected error") } - if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want { - t.Errorf("error = %q, want %q", got, want) + if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\nCheck: have you activated your role or group in PIM?"; !strings.HasPrefix(got, want) { + t.Errorf("error = %q, want %q…", got, want) + } + if hint := deniedHint("Client address is not authorized and caller is not a trusted service."); !strings.Contains(hint, "network") { + t.Errorf("firewall hint = %q", hint) } var re *azcore.ResponseError if !errors.As(err, &re) || re.StatusCode != 403 { @@ -184,6 +187,13 @@ func TestForbiddenIsShortened(t *testing.T) { } } +func TestShortenPolicyDenied(t *testing.T) { + msg := "The user, group or application 'appid=04b07795;oid=1234;numgroups=83;iss=https://sts.windows.net/t/' does not have secrets list permission on key vault 'kv-app;location=norwayeast'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287" + if got := shortenDenied(msg); got != "no secrets list permission in the vault's access policies" { + t.Errorf("shortenDenied = %q", got) + } +} + func TestShortenDenied(t *testing.T) { msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'" want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action" diff --git a/internal/provider/azure/convert.go b/internal/provider/azure/convert.go index 038b316..f29fca0 100644 --- a/internal/provider/azure/convert.go +++ b/internal/provider/azure/convert.go @@ -11,6 +11,7 @@ import ( "fmt" "math/big" "net/http" + "regexp" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -282,10 +283,17 @@ func cleanError(err error) error { return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err} } +var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`) + // shortenDenied reduces Key Vault's RBAC denial, which runs to several // sentences plus caller and resource IDs, to its first sentence and the // missing action. func shortenDenied(msg string) string { + // Access policies: "The user, group or application 'appid=…;oid=…' + // does not have secrets list permission on key vault 'kv;location=…'". + if m := policyDenied.FindStringSubmatch(msg); m != nil { + return "no " + m[1] + " permission in the vault's access policies" + } const marker = "Action: '" i := strings.Index(msg, marker) if i < 0 { @@ -314,7 +322,21 @@ func (e *serviceError) Error() string { if e.msg != "" { s += ": " + e.msg } + if e.status == http.StatusForbidden { + s += "\n" + deniedHint(e.msg) + } return s } +// deniedHint is what to check when Key Vault refuses: the network, for +// its firewall's refusals, else access. On a line of its own, after the +// message. +func deniedHint(msg string) string { + if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") || + strings.Contains(msg, "private link") { + return "Check: are you on a network the vault lets in (VPN, office)? Its firewall limits where it can be reached from." + } + return "Check: have you activated your role or group in PIM? Has the vault's owner given you access (a Key Vault role, or its access policies)?" +} + func (e *serviceError) Unwrap() error { return e.err } -- 2.54.0 From 32a2c3be20a887b8f72f293d735654beb4f485ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 14:07:12 +0200 Subject: [PATCH 11/13] feat: what to check when access is refused, as a list, for Databricks too Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/databricks.md | 4 ++ internal/provider/azure/azure_test.go | 6 +-- internal/provider/azure/convert.go | 26 +++++++----- internal/provider/databricks/databricks.go | 41 +++++++++++++++++-- .../provider/databricks/databricks_test.go | 21 ++++++++++ internal/provider/provider.go | 18 ++++++++ internal/ui/panes/list.go | 21 +++++++++- 7 files changed, 120 insertions(+), 17 deletions(-) diff --git a/docs/databricks.md b/docs/databricks.md index e02fc74..30ddcaf 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)). +When Databricks refuses, the pane says what to check: access to the +workspace and READ on the scope, or, when the workspace's IP access list +refused, the network you're on. + 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. diff --git a/internal/provider/azure/azure_test.go b/internal/provider/azure/azure_test.go index 62f783e..a2417f7 100644 --- a/internal/provider/azure/azure_test.go +++ b/internal/provider/azure/azure_test.go @@ -175,11 +175,11 @@ func TestForbiddenIsShortened(t *testing.T) { if err == nil { t.Fatal("expected error") } - if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\nCheck: have you activated your role or group in PIM?"; !strings.HasPrefix(got, want) { + if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\n\nCheck:\n• your role or group is activated in PIM\n• "; !strings.HasPrefix(got, want) { t.Errorf("error = %q, want %q…", got, want) } - if hint := deniedHint("Client address is not authorized and caller is not a trusted service."); !strings.Contains(hint, "network") { - t.Errorf("firewall hint = %q", hint) + if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") { + t.Errorf("firewall checks = %q", checks) } var re *azcore.ResponseError if !errors.As(err, &re) || re.StatusCode != 403 { diff --git a/internal/provider/azure/convert.go b/internal/provider/azure/convert.go index f29fca0..32f658c 100644 --- a/internal/provider/azure/convert.go +++ b/internal/provider/azure/convert.go @@ -280,7 +280,11 @@ func cleanError(err error) error { } } } - return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err} + se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err} + if se.status == http.StatusForbidden { + return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)} + } + return se } var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`) @@ -322,21 +326,23 @@ func (e *serviceError) Error() string { if e.msg != "" { s += ": " + e.msg } - if e.status == http.StatusForbidden { - s += "\n" + deniedHint(e.msg) - } return s } -// deniedHint is what to check when Key Vault refuses: the network, for -// its firewall's refusals, else access. On a line of its own, after the -// message. -func deniedHint(msg string) string { +// deniedChecks are what to check when Key Vault refuses: the network, +// for its firewall's refusals, else access. +func deniedChecks(msg string) []string { if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") || strings.Contains(msg, "private link") { - return "Check: are you on a network the vault lets in (VPN, office)? Its firewall limits where it can be reached from." + return []string{ + "you're on a network the vault lets in (VPN, office)", + "the vault's owner has let your network in", + } + } + return []string{ + "your role or group is activated in PIM", + "the vault's owner has given you access: a Key Vault role, or its access policies", } - return "Check: have you activated your role or group in PIM? Has the vault's owner given you access (a Key Vault role, or its access policies)?" } func (e *serviceError) Unwrap() error { return e.err } diff --git a/internal/provider/databricks/databricks.go b/internal/provider/databricks/databricks.go index bb54095..8c86a4e 100644 --- a/internal/provider/databricks/databricks.go +++ b/internal/provider/databricks/databricks.go @@ -317,15 +317,47 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre return v, nil } +// deniedChecks are what to check when Databricks refuses: the network, +// when the workspace's IP access list refused, else access. +func deniedChecks(msg string) []string { + if strings.Contains(msg, "IP ACL") || strings.Contains(msg, "IP access list") { + return []string{ + "you're on a network the workspace lets in (VPN, office)", + "the workspace's admin has let your network in", + } + } + return []string{ + "you have access to the workspace", + "the scope's manager has given you READ on it, or a group you're in", + } +} + var errNoFallback = errors.New("no Key Vault fallback") -// combine reports both the Databricks and the Key Vault failure. An auth -// error from either stays reachable with errors.As. +// combine reports both the Databricks and the Key Vault failure, with +// both their checks after. An auth error from either stays reachable +// with errors.As. func combine(dbxErr, kvErr error) error { if errors.Is(kvErr, errNoFallback) { return dbxErr } - return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr) + var checks []string + strip := func(err error) error { + if d, ok := err.(*provider.Denied); ok { + for _, c := range d.Checks { + if !slices.Contains(checks, c) { + checks = append(checks, c) + } + } + return d.Err + } + return err + } + err := fmt.Errorf("%w; Key Vault fallback: %w", strip(dbxErr), strip(kvErr)) + if len(checks) > 0 { + return &provider.Denied{Err: err, Checks: checks} + } + return err } // classify turns authentication failures into *provider.AuthError: a 401 @@ -336,6 +368,9 @@ func classify(pr profile, err error) error { } var api *apierr.APIError if errors.As(err, &api) { + if api.StatusCode == 403 { + return &provider.Denied{Err: err, Checks: deniedChecks(api.Message)} + } if api.StatusCode != 401 { return err } diff --git a/internal/provider/databricks/databricks_test.go b/internal/provider/databricks/databricks_test.go index 0df6bf0..33efc6a 100644 --- a/internal/provider/databricks/databricks_test.go +++ b/internal/provider/databricks/databricks_test.go @@ -145,6 +145,8 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server { map[string]any{"principal": "users", "permission": "READ"}, map[string]any{"principal": "admins", "permission": "MANAGE"}, }}) + case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked": + write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"}) 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": @@ -315,3 +317,22 @@ func TestScopeDetails(t *testing.T) { t.Errorf("locked: %+v, %v", attrs, err) } } + +func TestDeniedSaysWhatToCheck(t *testing.T) { + p := newTestProvider(t, workspaceAPI(t, 0), nil) + _, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret) + var d *provider.Denied + if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• you have access to the workspace") { + t.Errorf("err %q", err) + } + if got := deniedChecks("Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") { + t.Errorf("IP ACL checks %q", got) + } + + // Both refused: one message, then both lists of checks. + err = combine(&provider.Denied{Err: errors.New("dbx"), Checks: []string{"a", "b"}}, + &provider.Denied{Err: errors.New("kv"), Checks: []string{"b", "c"}}) + if got := err.Error(); got != "dbx; Key Vault fallback: kv\n\nCheck:\n• a\n• b\n• c" { + t.Errorf("combined %q", got) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4e7dfab..c47dccc 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -12,6 +12,7 @@ import ( "context" "errors" "fmt" + "strings" "time" ) @@ -202,6 +203,23 @@ type Account struct { Copy, CopyLabel string } +// Denied is a refusal with what to check, from what the service said: a +// role to activate in PIM, access to ask for, the network. Error is the +// refusal, then the checks as a list. +type Denied struct { + Err error + Checks []string +} + +func (d *Denied) Error() string { + if len(d.Checks) == 0 { + return d.Err.Error() + } + return d.Err.Error() + "\n\nCheck:\n• " + strings.Join(d.Checks, "\n• ") +} + +func (d *Denied) Unwrap() error { return d.Err } + // 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 diff --git a/internal/ui/panes/list.go b/internal/ui/panes/list.go index aa685fd..0b9478f 100644 --- a/internal/ui/panes/list.go +++ b/internal/ui/panes/list.go @@ -334,7 +334,7 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string { case l.Loading && len(l.items) == 0: body = append(body, " "+spin+" loading…") case l.Err != nil: - for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") { + for _, line := range wrapLines(l.Err.Error(), inner-1) { body = append(body, st.Error.Render(" "+line)) } case len(l.visible) == 0 && len(l.items) > 0: @@ -393,3 +393,22 @@ func (l *List[T]) scroll() { } l.offset = max(0, min(l.offset, len(l.visible)-rows)) } + +// wrapLines wraps text to width, line by line; a "• " item's further +// lines line up under its text. +func wrapLines(text string, width int) []string { + var out []string + for _, line := range strings.Split(text, "\n") { + indent := "" + if strings.HasPrefix(line, "• ") { + indent = " " + } + for i, l := range strings.Split(ansi.Wrap(line, max(1, width-len(indent)), " -/"), "\n") { + if i > 0 { + l = indent + l + } + out = append(out, l) + } + } + return out +} -- 2.54.0 From 4b2a6a8ae8b8108c2dda472311102ee650a4a5ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 14:20:19 +0200 Subject: [PATCH 12/13] feat: a refusing Databricks profile that signs in through az says to check the az account Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/provider/databricks/databricks.go | 15 ++++++++++----- internal/provider/databricks/databricks_test.go | 9 +++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/internal/provider/databricks/databricks.go b/internal/provider/databricks/databricks.go index 8c86a4e..63883c6 100644 --- a/internal/provider/databricks/databricks.go +++ b/internal/provider/databricks/databricks.go @@ -318,18 +318,23 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre } // deniedChecks are what to check when Databricks refuses: the network, -// when the workspace's IP access list refused, else access. -func deniedChecks(msg string) []string { +// when the workspace's IP access list refused, else access, and for a +// profile signed in through az, which account az is on. +func deniedChecks(pr profile, msg string) []string { if strings.Contains(msg, "IP ACL") || strings.Contains(msg, "IP access list") { return []string{ "you're on a network the workspace lets in (VPN, office)", "the workspace's admin has let your network in", } } - return []string{ + var checks []string + if pr.authType == "azure-cli" { + checks = append(checks, "az is signed in as the account with access: profile "+pr.name+" uses your az login (az account show)") + } + return append(checks, "you have access to the workspace", "the scope's manager has given you READ on it, or a group you're in", - } + ) } var errNoFallback = errors.New("no Key Vault fallback") @@ -369,7 +374,7 @@ func classify(pr profile, err error) error { var api *apierr.APIError if errors.As(err, &api) { if api.StatusCode == 403 { - return &provider.Denied{Err: err, Checks: deniedChecks(api.Message)} + return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)} } if api.StatusCode != 401 { return err diff --git a/internal/provider/databricks/databricks_test.go b/internal/provider/databricks/databricks_test.go index 33efc6a..89909c5 100644 --- a/internal/provider/databricks/databricks_test.go +++ b/internal/provider/databricks/databricks_test.go @@ -322,10 +322,15 @@ func TestDeniedSaysWhatToCheck(t *testing.T) { p := newTestProvider(t, workspaceAPI(t, 0), nil) _, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret) var d *provider.Denied - if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• you have access to the workspace") { + // The test profile signs in through az: that's the first check. + if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• az is signed in as the account with access: profile prod uses your az login") || + !strings.Contains(err.Error(), "\n• you have access to the workspace") { t.Errorf("err %q", err) } - if got := deniedChecks("Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") { + if got := deniedChecks(profile{authType: "pat"}, "denied"); strings.Contains(got[0], "az") { + t.Errorf("a token profile's checks %q", got) + } + if got := deniedChecks(profile{}, "Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") { t.Errorf("IP ACL checks %q", got) } -- 2.54.0 From 18918db1d42b769efc4f6c4cd0ebea567fce8da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 14:23:14 +0200 Subject: [PATCH 13/13] refactor: no group list in an account's details, and no Microsoft Graph call for it Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/azure.md | 10 ++-- internal/provider/azure/account.go | 70 ------------------------- internal/provider/azure/account_test.go | 47 ----------------- internal/provider/azure/azure.go | 12 ++--- internal/provider/provider.go | 7 --- internal/ui/app/app.go | 10 +--- internal/ui/app/app_test.go | 33 +++--------- internal/ui/app/fields.go | 69 +++--------------------- 8 files changed, 26 insertions(+), 232 deletions(-) diff --git a/docs/azure.md b/docs/azure.md index 2686d85..6a20e0f 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -16,12 +16,10 @@ A service principal is used instead when `AZURE_CLIENT_ID`, On an account's group row, the fourth 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, read from az's account list -and the token lazysecrets already has. Below them are the groups the -account is in, nested ones too: those come from Microsoft Graph, once a -session, the first time the pane shows them. `y` on the row copies the -object ID, the one access policies and role assignments ask for; `tab` -goes into the pane, where `j`/`k` pick any field or group, `/` filters -them (`/tenant`, `/platform`) and `y` copies the selected one. +and the token lazysecrets already has: no requests of their own. `y` on +the row copies the object ID, the one access policies and role +assignments ask for; `l` goes into the pane, where `j`/`k` pick any +field, `/` filters them (`/tenant`) and `y` copies the selected one. 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` diff --git a/internal/provider/azure/account.go b/internal/provider/azure/account.go index 690234e..8b85691 100644 --- a/internal/provider/azure/account.go +++ b/internal/provider/azure/account.go @@ -1,15 +1,10 @@ package azure import ( - "context" - "encoding/json" "fmt" - "net/http" "slices" "strings" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -160,68 +155,3 @@ func directoryRoles(wids any) string { slices.Sort(names) return strings.Join(names, ", ") } - -// graphGroups lists the signed-in user's groups, nested ones too, from -// Microsoft Graph with az's token for it. -const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999" - -var _ provider.AccountGroups = (*Provider)(nil) - -// Groups lists the groups the account is a member of, by name. -func (p *Provider) Groups(ctx context.Context) ([]string, error) { - if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") { - return nil, provider.ErrUnsupported - } - tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}}) - if err != nil { - return nil, err - } - var names []string - for next := graphGroups; next != ""; { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+tok.Token) - resp, err := p.http().Do(req) - if err != nil { - return nil, err - } - var page struct { - Value []struct { - Type string `json:"@odata.type"` - Name string `json:"displayName"` - } `json:"value"` - Next string `json:"@odata.nextLink"` - Error *struct { - Message string `json:"message"` - } `json:"error"` - } - err = json.NewDecoder(resp.Body).Decode(&page) - resp.Body.Close() - switch { - case err != nil: - return nil, fmt.Errorf("listing groups from Microsoft Graph: %w", err) - case page.Error != nil: - return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", page.Error.Message) - case resp.StatusCode != http.StatusOK: - return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", resp.Status) - } - for _, v := range page.Value { - if v.Type == "#microsoft.graph.group" && v.Name != "" { - names = append(names, v.Name) - } - } - next = page.Next - } - slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }) - return names, nil -} - -// http is the client for Microsoft Graph; tests replace it. -func (p *Provider) http() *http.Client { - if p.graphClient != nil { - return p.graphClient - } - return http.DefaultClient -} diff --git a/internal/provider/azure/account_test.go b/internal/provider/azure/account_test.go index a951ce8..0093ea9 100644 --- a/internal/provider/azure/account_test.go +++ b/internal/provider/azure/account_test.go @@ -1,17 +1,7 @@ package azure import ( - "context" - "errors" - "io" - "net/http" - "slices" - "strings" "testing" - "time" - - "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -75,40 +65,3 @@ func TestSignInMethods(t *testing.T) { } } } - -type staticToken struct{} - -func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { - return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil -} - -type graphPages map[string]string // by URL - -func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) { - body, ok := g[r.URL.String()] - if !ok || r.Header.Get("Authorization") != "Bearer tok" { - return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil - } - return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil -} - -func TestGroups(t *testing.T) { - next := "https://graph.microsoft.com/v1.0/next" - p := &Provider{ - cred: staticToken{}, - account: &accountDetails{account: account{user: "alice@contoso.com"}}, - graphClient: &http.Client{Transport: graphPages{ - graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"}, - {"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`, - next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`, - }}, - } - got, err := p.Groups(context.Background()) - if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) { - t.Errorf("groups %q, %v", got, err) - } - p.account.cloud = "AzureChinaCloud" - if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) { - t.Errorf("other cloud: %v", err) - } -} diff --git a/internal/provider/azure/azure.go b/internal/provider/azure/azure.go index f3fda6f..4a7bbc1 100644 --- a/internal/provider/azure/azure.go +++ b/internal/provider/azure/azure.go @@ -9,7 +9,6 @@ import ( "crypto/x509" "errors" "fmt" - "net/http" "regexp" "slices" "strings" @@ -37,12 +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 - account *accountDetails // nil for a service principal from AZURE_* - graphClient *http.Client // for Groups; nil for the default - 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/provider.go b/internal/provider/provider.go index c47dccc..2b3ae9f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -227,10 +227,3 @@ func (d *Denied) Unwrap() error { return d.Err } 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. -type AccountGroups interface { - Groups(ctx context.Context) ([]string, error) -} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 9fd1c8e..4604886 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -172,12 +172,10 @@ type Model struct { clip Clipboard clipClear time.Duration openURL func(string) error - // fields is the fourth pane for anything but a secret, with each - // account's groups as they load; lastLeft is the left pane it - // describes while the fourth has focus (fields.go). + // fields is the fourth pane for anything but a secret; lastLeft is + // the left pane it describes while the fourth has focus (fields.go). 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 @@ -377,7 +375,6 @@ func New(providers []provider.Provider, opts Options) Model { getenv: os.Getenv, nvimOpen: nvimOpen, cache: newCache(), - groups: map[string]groupsState{}, details: map[string]detailsState{}, fields: panes.NewFields(), } @@ -590,9 +587,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case persistedMsg: m.handlePersisted(msg) - case groupsMsg: - m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err} - case detailsMsg: if msg.req == m.reqID[slotDetails] { m.done(slotDetails) diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index 948907b..deac928 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1106,25 +1106,16 @@ func TestAccountInDetails(t *testing.T) { } } -// grouped is an account in two groups. -type grouped struct{ described } - -func (grouped) Groups(context.Context) ([]string, error) { - return []string{"Platform team", "Vault readers"}, nil -} - func TestAccountPane(t *testing.T) { t.Parallel() - d := newDriver(t, grouped{described{fake.Azure(0)}}) - d.keys("g", "tab") // the account's row, then straight to its pane + d := newDriver(t, described{fake.Azure(0)}) + d.keys("g", "l") // the account's row, then its pane if d.m.focus != paneDetail { t.Fatalf("focus %v, want the fourth pane", d.m.focus) } scr := d.screen() - for _, want := range []string{"Alice Example", "groups 2", "Platform team", "Vault readers"} { - if !strings.Contains(scr, want) { - t.Errorf("missing %q:\n%s", want, scr) - } + if !strings.Contains(scr, "Alice Example") { + t.Errorf("account not shown:\n%s", scr) } bar := lastLine(scr) if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { @@ -1134,28 +1125,20 @@ func TestAccountPane(t *testing.T) { if got := d.clip().content; got != "oid-123" { t.Errorf("y on the object ID copied %q", got) } - d.keys("G", "y") - if got := d.clip().content; got != "Vault readers" { - t.Errorf("y on a group copied %q", got) - } // / filters it, labels too. d.keys("/") - d.typeText("vault") + d.typeText("object") d.keys("enter") - if scr := d.screen(); !strings.Contains(scr, "Vault readers") || strings.Contains(scr, "Platform team") { + if scr := d.screen(); !strings.Contains(scr, "oid-123") || strings.Contains(scr, "Alice Example") { t.Errorf("filtered:\n%s", scr) } - d.keys("y") - if got := d.clip().content; got != "Vault readers" { - t.Errorf("y in the filtered pane copied %q", got) - } d.keys("esc") - if !strings.Contains(d.screen(), "Platform team") { + if !strings.Contains(d.screen(), "Alice Example") { t.Errorf("filter not cleared:\n%s", d.screen()) } - d.keys("shift+tab") + d.keys("h") if d.m.focus != paneSources { t.Errorf("back to %v, want Sources", d.m.focus) } diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index cba5c27..bb6fa6f 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -1,7 +1,6 @@ package app import ( - "context" "errors" "fmt" "strings" @@ -14,20 +13,7 @@ import ( ) // The fourth pane shows Fields for anything but a secret. On a provider's -// row: its account, from what the provider knows locally, then the -// groups the account is in, loaded once a session when first shown. - -type groupsState struct { - names []string - err error - loading bool -} - -type groupsMsg struct { - provider string - names []string - err error -} +// row: its account, from what the provider knows locally. // A vault's, scope's or file's details beyond its listing load once a // session, when the cursor rests on it, and again with refresh. They're @@ -57,7 +43,6 @@ func (m Model) detailOf() pane { // syncFields shows the Fields pane for what the focused pane has selected, // unless that's a secret (or a vault with the Secrets pane in focus), which // the secret pane shows, as it does a revealed value whatever the focus. -// An account's groups load the first time. func (m *Model) syncFields() tea.Cmd { if m.detail.State() != panes.ValueHidden { // A value revealed (or being fetched) from any pane stays in @@ -76,7 +61,7 @@ func (m *Model) syncFields() tea.Cmd { case m.selectedProblem() != "": m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem())) case r.kind == rowGroup: - return m.showAccount(r) + m.showAccount(r) case r.kind == rowSource: m.fields.Show(r.source.Name, m.sourceFields(r.source)) default: @@ -98,9 +83,8 @@ func (m *Model) syncFields() tea.Cmd { return nil } -// showAccount shows a provider's account, loading its groups the first -// time. -func (m *Model) showAccount(r sourceRow) tea.Cmd { +// showAccount shows a provider's account. +func (m *Model) showAccount(r sourceRow) { p := m.byID[r.provider] var fields []panes.Field if a, ok := p.(provider.Accounts); ok { @@ -110,28 +94,11 @@ func (m *Model) showAccount(r sourceRow) tea.Cmd { } } } - var cmd tea.Cmd - if g, ok := p.(provider.AccountGroups); ok { - st, seen := m.groups[r.provider] - if !seen { - st = groupsState{loading: true} - m.groups[r.provider] = st - id, timeout := r.provider, m.timeout - cmd = func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - names, err := g.Groups(ctx) - return groupsMsg{provider: id, names: names, err: err} - } - } - fields = append(fields, groupFields(st)...) - } if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 { hint := emptyHint(p.Kind()) fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL}) } m.fields.Show(p.Label(), fields) - return cmd } // sourceFields describe a subscription, workspace or repo. @@ -248,30 +215,8 @@ func (m Model) problemFields(problem string) []panes.Field { return out } -// fieldName names a copied field: its label, or for a group, the group. +// fieldName names a copied field: its label, or a row of several under +// one label (a tag), its value. func fieldName(f panes.Field) string { - if f.Label == "" || strings.HasPrefix(f.Label, "groups") { - return "group " + f.Value - } - return f.Label -} - -// groupFields are the groups' rows: labelled on the first. -func groupFields(st groupsState) []panes.Field { - switch { - case st.loading: - return []panes.Field{{Label: "groups", Value: "loading…", Info: true}} - case errors.Is(st.err, provider.ErrUnsupported): - return nil - case st.err != nil: - return []panes.Field{{Label: "groups", Value: "couldn't list them: " + st.err.Error(), Info: true}} - case len(st.names) == 0: - return []panes.Field{{Label: "groups", Value: "none", Info: true}} - } - out := make([]panes.Field, len(st.names)) - for i, n := range st.names { - out[i] = panes.Field{Value: n} - } - out[0].Label = fmt.Sprintf("groups %d", len(st.names)) - return out + return cmpOr(f.Label, f.Value) } -- 2.54.0