From 09bacd8438010b0932af5342b1ba6ca4fb48f6b3 Mon Sep 17 00:00:00 2001 From: keb Date: Sun, 11 Oct 2026 14:24:08 +0200 Subject: [PATCH] feat: `l` for details; details for vaults, scopes and files; getting started; the age of cached lists (#17) - `l` goes to the details of what is selected; `enter` still drills in - vaults, scopes and files show their details (vault settings from the listing, no extra requests) - with nothing to show, the right pane says how to get started - a list from the cache says how old it is while it reloads - what is expiring, in the status pane and `lazysecrets expiring` - when access is refused, what to check, for Azure and Databricks - no group list in an account's details, and no Microsoft Graph call for it Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 9 +- cmd/lazysecrets/expiring.go | 192 ++++++++++++++++++ cmd/lazysecrets/expiring_test.go | 48 +++++ cmd/lazysecrets/main.go | 24 ++- docs/azure.md | 43 +++- docs/config.md | 5 +- docs/databricks.md | 8 + docs/keys.md | 8 +- docs/repos.md | 7 + internal/provider/azure/account.go | 70 ------- internal/provider/azure/account_test.go | 47 ----- internal/provider/azure/azure.go | 20 +- internal/provider/azure/azure_test.go | 61 +++++- internal/provider/azure/convert.go | 30 ++- internal/provider/azure/details.go | 158 ++++++++++++++ internal/provider/databricks/databricks.go | 46 ++++- .../provider/databricks/databricks_test.go | 50 +++++ internal/provider/databricks/details.go | 43 ++++ internal/provider/provider.go | 32 ++- internal/provider/repo/details.go | 62 ++++++ internal/provider/repo/repo.go | 65 +++++- internal/provider/repo/repo_test.go | 44 ++++ internal/ui/app/app.go | 62 +++++- internal/ui/app/app_test.go | 111 +++++++--- internal/ui/app/expiring.go | 75 +++++++ internal/ui/app/fields.go | 118 ++++++----- internal/ui/app/getstarted.go | 66 ++++++ internal/ui/app/persist_test.go | 26 +++ internal/ui/app/status.go | 5 + internal/ui/keys/keys.go | 7 +- internal/ui/panes/fields.go | 5 +- internal/ui/panes/list.go | 28 ++- 32 files changed, 1321 insertions(+), 254 deletions(-) create mode 100644 cmd/lazysecrets/expiring.go create mode 100644 cmd/lazysecrets/expiring_test.go create mode 100644 internal/provider/azure/details.go create mode 100644 internal/provider/databricks/details.go create mode 100644 internal/provider/repo/details.go create mode 100644 internal/ui/app/expiring.go create mode 100644 internal/ui/app/getstarted.go diff --git a/README.md b/README.md index d01adc4..87ab4b6 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. @@ -60,12 +61,14 @@ 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 | |---|---| -| `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/cmd/lazysecrets/expiring.go b/cmd/lazysecrets/expiring.go new file mode 100644 index 0000000..49dc5c2 --- /dev/null +++ b/cmd/lazysecrets/expiring.go @@ -0,0 +1,192 @@ +package main + +import ( + "cmp" + "context" + "errors" + "flag" + "fmt" + "io" + "maps" + "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. + +// 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 + +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) + // 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: %s\n", what, briefly(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) + 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) + } + } + }) + } + } + }) + } + }) + } + // 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 { + 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 { + fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: they weren't checked\n", failed) + return errIncomplete + } + 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 +} + +// 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 new file mode 100644 index 0000000..924e5db --- /dev/null +++ b/cmd/lazysecrets/expiring_test.go @@ -0,0 +1,48 @@ +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: can't list ") || strings.Count(errs.String(), "kv-restricted") != 1 { + 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()) + } + + // 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 571de9e..79f5584 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,12 @@ import ( ) func main() { - if err := run(); err != nil { + switch err := run(); { + case errors.Is(err, errExpiring): + os.Exit(1) // listed: a pipeline fails on it + case errors.Is(err, errIncomplete): + os.Exit(2) + case err != nil: fmt.Fprintln(os.Stderr, "lazysecrets:", err) os.Exit(1) } @@ -118,6 +124,10 @@ 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, + 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] @@ -140,6 +150,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 +161,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 +311,15 @@ 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 { + 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) + } + if getRef != nil { ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() diff --git a/docs/azure.md b/docs/azure.md index 85a9e91..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` @@ -40,8 +38,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 @@ -64,3 +64,30 @@ 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): + +```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`. 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 +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/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/docs/databricks.md b/docs/databricks.md index d44ca7d..30ddcaf 100644 --- a/docs/databricks.md +++ b/docs/databricks.md @@ -13,6 +13,14 @@ 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. + Hide profiles you don't want in the [config](config.md): ```yaml 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/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/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 b7b0629..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 @@ -185,10 +183,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 +222,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 +243,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 4f6bc03..a2417f7 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" @@ -174,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.\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 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 { @@ -183,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" @@ -417,3 +428,49 @@ 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 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"} + 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) + } + } + + // 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/convert.go b/internal/provider/azure/convert.go index 038b316..32f658c 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" @@ -279,13 +280,24 @@ 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`) + // 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 { @@ -317,4 +329,20 @@ func (e *serviceError) Error() string { return s } +// 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 []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", + } +} + func (e *serviceError) Unwrap() error { return e.err } diff --git a/internal/provider/azure/details.go b/internal/provider/azure/details.go new file mode 100644 index 0000000..714005c --- /dev/null +++ b/internal/provider/azure/details.go @@ -0,0 +1,158 @@ +package azure + +import ( + "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" +) + +// 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 +} + +// 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}) + } + } + 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(v.rbac, false) { + add("access", "Azure roles (RBAC)") + } else { + add("access", fmt.Sprintf("access policies (%d)", v.policies)) + } + if on(v.softDelete, true) { + days := int32(90) + 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(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") + } + 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) + for i, k := range keys { + label := "" + if i == 0 { + label = "tags" + } + out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]}) + } + return out +} + +// 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.go b/internal/provider/databricks/databricks.go index bb54095..63883c6 100644 --- a/internal/provider/databricks/databricks.go +++ b/internal/provider/databricks/databricks.go @@ -317,15 +317,52 @@ 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, 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", + } + } + 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") -// 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 +373,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(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 5c47f6b..89909c5 100644 --- a/internal/provider/databricks/databricks_test.go +++ b/internal/provider/databricks/databricks_test.go @@ -140,6 +140,15 @@ 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/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": write(w, 200, map[string]any{"key": q.Get("key"), "value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))}) @@ -291,3 +300,44 @@ 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) + } +} + +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 + // 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(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) + } + + // 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/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..2b3ae9f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -12,6 +12,7 @@ import ( "context" "errors" "fmt" + "strings" "time" ) @@ -93,6 +94,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. @@ -107,6 +110,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,9 +203,27 @@ type Account struct { Copy, CopyLabel string } -// 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) +// 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 +// over the network; ErrUnsupported means there's nothing more for c. +type ContainerDetails interface { + Details(ctx context.Context, c Container) ([]Attr, error) } 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..4604886 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 ) @@ -171,12 +172,11 @@ 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 now func() time.Time @@ -375,7 +375,7 @@ 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(), } if m.clip == nil { @@ -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 { @@ -585,8 +587,15 @@ 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) + } + 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)) @@ -821,6 +830,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 @@ -841,6 +852,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++ @@ -1032,6 +1049,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: @@ -1154,6 +1172,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 +1189,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 +1319,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 @@ -1738,6 +1778,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..deac928 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 { @@ -1104,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") { @@ -1132,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) } @@ -1236,3 +1221,77 @@ 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) + } +} + +// 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) + } +} + +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/fields.go b/internal/ui/app/fields.go index 6a0f253..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,19 +13,22 @@ 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. +// row: its account, from what the provider knows locally. -type groupsState struct { - names []string +// 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 +// extra: moving on cancels the load, and search never waits for them. +type detailsState struct { + attrs []provider.Attr err error loading bool } -type groupsMsg struct { - provider string - names []string - err error +type detailsMsg struct { + req int + key string + attrs []provider.Attr + err error } // detailOf is the pane the fourth pane describes: the one in focus, or, @@ -41,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 @@ -53,12 +54,14 @@ 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() != "": 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: @@ -68,7 +71,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) } @@ -78,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 { @@ -90,24 +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. @@ -171,9 +162,48 @@ 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}) + 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 + } + // 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 // pane, in red. func (m Model) problemFields(problem string) []panes.Field { @@ -185,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) } 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"} +} 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/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/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": {"`"}, 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 { diff --git a/internal/ui/panes/list.go b/internal/ui/panes/list.go index 21252b2..0b9478f 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() { @@ -330,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: @@ -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) } @@ -386,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 +}