feat: l for details; details for vaults, scopes and files; getting started; the age of cached lists
#17
No files matched your search
@@ -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
|
- **Favourites**: star the secrets you use most and get back to them in one
|
||||||
key.
|
key.
|
||||||
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
- **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
|
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||||
on a secret.
|
on a secret.
|
||||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
||||||
@@ -60,12 +61,14 @@ lazysecrets # start lazysecrets
|
|||||||
lazysecrets --demo # test with demo data
|
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:
|
use most:
|
||||||
|
|
||||||
| Key | Action |
|
| 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 |
|
| `/` | filter the list you're in |
|
||||||
| `space` | reveal / hide the value |
|
| `space` | reveal / hide the value |
|
||||||
| `y` | copy the value (cleared from the clipboard after 30s) |
|
| `y` | copy the value (cleared from the clipboard after 30s) |
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-1
@@ -4,6 +4,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -33,7 +34,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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)
|
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -118,6 +124,10 @@ func run() error {
|
|||||||
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
||||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||||
for scripts: VAR=$(lazysecrets get <ref>)
|
for scripts: VAR=$(lazysecrets get <ref>)
|
||||||
|
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
|
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||||
config applied), as config to copy and edit
|
config applied), as config to copy and edit
|
||||||
lazysecrets handler install [--terminal NAME]
|
lazysecrets handler install [--terminal NAME]
|
||||||
@@ -140,6 +150,7 @@ Flags:
|
|||||||
}
|
}
|
||||||
|
|
||||||
var open, getRef *ref.Ref
|
var open, getRef *ref.Ref
|
||||||
|
var expiringArgs []string
|
||||||
printKeys := false
|
printKeys := false
|
||||||
switch args := flag.Args(); {
|
switch args := flag.Args(); {
|
||||||
case len(args) == 1 && args[0] == "keybindings":
|
case len(args) == 1 && args[0] == "keybindings":
|
||||||
@@ -150,6 +161,8 @@ Flags:
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
getRef = &r
|
getRef = &r
|
||||||
|
case len(args) > 0 && args[0] == "expiring":
|
||||||
|
expiringArgs = append([]string{}, args[1:]...)
|
||||||
case len(args) > 0 && args[0] == "handler":
|
case len(args) > 0 && args[0] == "handler":
|
||||||
return handler(*configPath, args[1:])
|
return handler(*configPath, args[1:])
|
||||||
case len(args) > 0 && args[0] == "launch":
|
case len(args) > 0 && args[0] == "launch":
|
||||||
@@ -298,6 +311,15 @@ Flags:
|
|||||||
// the standard logger, which would draw over the TUI.
|
// the standard logger, which would draw over the TUI.
|
||||||
log.SetOutput(io.Discard)
|
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 {
|
if getRef != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
+35
-8
@@ -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,
|
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
|
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
|
(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
|
and the token lazysecrets already has: no requests of their own. `y` on
|
||||||
account is in, nested ones too: those come from Microsoft Graph, once a
|
the row copies the object ID, the one access policies and role
|
||||||
session, the first time the pane shows them. `y` on the row copies the
|
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
|
||||||
object ID, the one access policies and role assignments ask for; `tab`
|
field, `/` filters them (`/tenant`) and `y` copies the selected one.
|
||||||
goes into the pane, where `j`/`k` pick any field or group, `/` filters
|
|
||||||
them (`/tenant`, `/platform`) and `y` copies the selected one.
|
|
||||||
|
|
||||||
If an account's az login has expired, the status line says so once, and the
|
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`
|
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 secret values | *Key Vault Secrets User* |
|
||||||
| read certificate details | *Key Vault Certificate User* |
|
| read certificate details | *Key Vault Certificate User* |
|
||||||
|
|
||||||
A missing permission is shown in the pane, including the action that was
|
A missing permission is shown in the pane, shortened to what was denied
|
||||||
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
|
## 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
|
key (PEM public key) or certificate; private key material is never
|
||||||
fetched. `v` lists an item's versions. Items expiring within
|
fetched. `v` lists an item's versions. Items expiring within
|
||||||
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
`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.
|
||||||
+3
-2
@@ -114,8 +114,9 @@ What lazysecrets has loaded is kept in
|
|||||||
you: the Sources lists, vaults and scopes, and secrets' names and metadata
|
you: the Sources lists, vaults and scopes, and secrets' names and metadata
|
||||||
(tags, dates, references), never values. On the next start they show at
|
(tags, dates, references), never values. On the next start they show at
|
||||||
once, and anything older than two minutes reloads in the background when
|
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
|
you go to it, or when Go to searches it. While it does, the pane's
|
||||||
fetch the value live.
|
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
|
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
|
account you're no longer signed in to. Repos aren't cached: their files
|
||||||
|
|||||||
@@ -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
|
Databricks refuses, lazysecrets reads the backing vault directly with your
|
||||||
az login (which then needs the [Key Vault roles](azure.md#permissions)).
|
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):
|
Hide profiles you don't want in the [config](config.md):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+4
-4
@@ -13,7 +13,8 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `j`/`k`, `↑`/`↓` | move |
|
| `j`/`k`, `↑`/`↓` | move |
|
||||||
| `g`/`G` | top / bottom |
|
| `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 |
|
| `1`–`4` | jump to pane |
|
||||||
| `enter` / `esc`, `⌫` | drill in / back (also clears a filter or leaves the versions view) |
|
| `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 |
|
| `[` / `]` | 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
|
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
|
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
|
Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
|
||||||
that; there `j`/`k`, `/` and `y` work on its rows, and `shift+tab` goes
|
`/` and `y` work on its rows, and `h` goes back. A value you reveal stays in view whichever pane you're in, until
|
||||||
back. A value you reveal stays in view whichever pane you're in, until
|
|
||||||
it's hidden.
|
it's hidden.
|
||||||
|
|
||||||
On top, the status pane (`0`) shows the version, a newer release and how
|
On top, the status pane (`0`) shows the version, a newer release and how
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Opening files
|
||||||
|
|
||||||
`e` opens the selected file, or on a secret, its file at that key's line:
|
`e` opens the selected file, or on a secret, its file at that key's line:
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
package azure
|
package azure
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,68 +155,3 @@ func directoryRoles(wids any) string {
|
|||||||
slices.Sort(names)
|
slices.Sort(names)
|
||||||
return strings.Join(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
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,7 @@
|
|||||||
package azure
|
package azure
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"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"
|
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -40,7 +39,6 @@ type Provider struct {
|
|||||||
cred azcore.TokenCredential
|
cred azcore.TokenCredential
|
||||||
opts options
|
opts options
|
||||||
account *accountDetails // nil for a service principal from AZURE_*
|
account *accountDetails // nil for a service principal from AZURE_*
|
||||||
graphClient *http.Client // for Groups; nil for the default
|
|
||||||
subs *armsubscriptions.Client
|
subs *armsubscriptions.Client
|
||||||
graph *armresourcegraph.Client
|
graph *armresourcegraph.Client
|
||||||
|
|
||||||
@@ -185,10 +183,6 @@ func (p *Provider) tenant(subscriptionID string) string {
|
|||||||
return p.tenants[subscriptionID]
|
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) {
|
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||||
var out []provider.Container
|
var out []provider.Container
|
||||||
req := armresourcegraph.QueryRequest{
|
req := armresourcegraph.QueryRequest{
|
||||||
@@ -228,7 +222,7 @@ func graphVault(src provider.Source, row any) (provider.Container, bool) {
|
|||||||
if id == "" || name == "" || uri == "" {
|
if id == "" || name == "" || uri == "" {
|
||||||
return provider.Container{}, false
|
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) {
|
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{
|
out = append(out, provider.Container{
|
||||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -174,8 +175,11 @@ func TestForbiddenIsShortened(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; 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)
|
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
|
var re *azcore.ResponseError
|
||||||
if !errors.As(err, &re) || re.StatusCode != 403 {
|
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) {
|
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'"
|
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"
|
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
|
size := (len(b) - 1) / 2
|
||||||
return b[1 : 1+size], b[1+size:]
|
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)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
"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
|
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
|
||||||
// sentences plus caller and resource IDs, to its first sentence and the
|
// sentences plus caller and resource IDs, to its first sentence and the
|
||||||
// missing action.
|
// missing action.
|
||||||
func shortenDenied(msg string) string {
|
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: '"
|
const marker = "Action: '"
|
||||||
i := strings.Index(msg, marker)
|
i := strings.Index(msg, marker)
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
@@ -317,4 +329,20 @@ func (e *serviceError) Error() string {
|
|||||||
return s
|
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 }
|
func (e *serviceError) Unwrap() error { return e.err }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -317,15 +317,52 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
return v, nil
|
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")
|
var errNoFallback = errors.New("no Key Vault fallback")
|
||||||
|
|
||||||
// combine reports both the Databricks and the Key Vault failure. An auth
|
// combine reports both the Databricks and the Key Vault failure, with
|
||||||
// error from either stays reachable with errors.As.
|
// both their checks after. An auth error from either stays reachable
|
||||||
|
// with errors.As.
|
||||||
func combine(dbxErr, kvErr error) error {
|
func combine(dbxErr, kvErr error) error {
|
||||||
if errors.Is(kvErr, errNoFallback) {
|
if errors.Is(kvErr, errNoFallback) {
|
||||||
return dbxErr
|
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
|
// classify turns authentication failures into *provider.AuthError: a 401
|
||||||
@@ -336,6 +373,9 @@ func classify(pr profile, err error) error {
|
|||||||
}
|
}
|
||||||
var api *apierr.APIError
|
var api *apierr.APIError
|
||||||
if errors.As(err, &api) {
|
if errors.As(err, &api) {
|
||||||
|
if api.StatusCode == 403 {
|
||||||
|
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
|
||||||
|
}
|
||||||
if api.StatusCode != 401 {
|
if api.StatusCode != 401 {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||||
map[string]any{"key": "client-secret"},
|
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":
|
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
||||||
write(w, 200, map[string]any{"key": q.Get("key"),
|
write(w, 200, map[string]any{"key": q.Get("key"),
|
||||||
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + 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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,6 +94,8 @@ type Container struct {
|
|||||||
// secrets only.
|
// secrets only.
|
||||||
Types []ObjectType
|
Types []ObjectType
|
||||||
URL string // web page for the container, if any
|
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.
|
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||||
@@ -107,6 +110,7 @@ func (c Container) ObjectTypes() []ObjectType {
|
|||||||
type Attr struct {
|
type Attr struct {
|
||||||
Label string
|
Label string
|
||||||
Value 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.
|
// Object describes a secret, key or certificate without its value.
|
||||||
@@ -199,9 +203,27 @@ type Account struct {
|
|||||||
Copy, CopyLabel string
|
Copy, CopyLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountGroups is implemented by providers that can list the groups
|
// Denied is a refusal with what to check, from what the service said: a
|
||||||
// their account is a member of, directly or through other groups. It
|
// role to activate in PIM, access to ask for, the network. Error is the
|
||||||
// goes over the network.
|
// refusal, then the checks as a list.
|
||||||
type AccountGroups interface {
|
type Denied struct {
|
||||||
Groups(ctx context.Context) ([]string, error)
|
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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -816,9 +816,31 @@ func anyMatch(res []*regexp.Regexp, f string) bool {
|
|||||||
|
|
||||||
type sopsMeta struct {
|
type sopsMeta struct {
|
||||||
methods []string // age, azure_kv, …
|
methods []string // age, azure_kv, …
|
||||||
|
recipients []string // "age age1…", "azure_kv https://kv…/keys/sops"
|
||||||
modified time.Time
|
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
|
const maxFileSize = 4 << 20
|
||||||
|
|
||||||
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
|
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
|
||||||
@@ -848,6 +870,15 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
for _, k := range known {
|
for _, k := range known {
|
||||||
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
|
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
|
||||||
m.methods = append(m.methods, k)
|
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 {
|
if s, ok := doc.Sops["lastmodified"].(string); ok {
|
||||||
@@ -864,6 +895,7 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
for _, k := range known {
|
for _, k := range known {
|
||||||
if strings.Contains(text, "sops_"+k+"__") {
|
if strings.Contains(text, "sops_"+k+"__") {
|
||||||
m.methods = append(m.methods, k)
|
m.methods = append(m.methods, k)
|
||||||
|
m.recipients = append(m.recipients, envRecipients(text, k)...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
|
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
|
||||||
@@ -881,6 +913,35 @@ func readMeta(file string) (sopsMeta, bool) {
|
|||||||
return m, true
|
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 {
|
func formatOf(file string) string {
|
||||||
switch strings.ToLower(filepath.Ext(file)) {
|
switch strings.ToLower(filepath.Ext(file)) {
|
||||||
case ".yaml", ".yml":
|
case ".yaml", ".yml":
|
||||||
|
|||||||
@@ -477,3 +477,47 @@ func TestFile(t *testing.T) {
|
|||||||
t.Errorf("lines %v\nwant %v", lines, want)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-7
@@ -56,6 +56,7 @@ const (
|
|||||||
slotDescribe // extra metadata for the selected object
|
slotDescribe // extra metadata for the selected object
|
||||||
slotCopy // fetch a value straight to the clipboard
|
slotCopy // fetch a value straight to the clipboard
|
||||||
slotVersions // count the selected object's versions
|
slotVersions // count the selected object's versions
|
||||||
|
slotDetails // a vault's, scope's or file's details
|
||||||
slotCount
|
slotCount
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -171,12 +172,11 @@ type Model struct {
|
|||||||
clip Clipboard
|
clip Clipboard
|
||||||
clipClear time.Duration
|
clipClear time.Duration
|
||||||
openURL func(string) error
|
openURL func(string) error
|
||||||
// fields is the fourth pane for anything but a secret, with each
|
// fields is the fourth pane for anything but a secret; lastLeft is
|
||||||
// account's groups as they load; lastLeft is the left pane it
|
// the left pane it describes while the fourth has focus (fields.go).
|
||||||
// describes while the fourth has focus (fields.go).
|
|
||||||
fields panes.Fields
|
fields panes.Fields
|
||||||
lastLeft pane
|
lastLeft pane
|
||||||
groups map[string]groupsState
|
details map[string]detailsState // by containerKey
|
||||||
getenv func(string) string
|
getenv func(string) string
|
||||||
nvimOpen func(server, path string, line int) error // edit.go
|
nvimOpen func(server, path string, line int) error // edit.go
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
@@ -375,7 +375,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
getenv: os.Getenv,
|
getenv: os.Getenv,
|
||||||
nvimOpen: nvimOpen,
|
nvimOpen: nvimOpen,
|
||||||
cache: newCache(),
|
cache: newCache(),
|
||||||
groups: map[string]groupsState{},
|
details: map[string]detailsState{},
|
||||||
fields: panes.NewFields(),
|
fields: panes.NewFields(),
|
||||||
}
|
}
|
||||||
if m.clip == nil {
|
if m.clip == nil {
|
||||||
@@ -456,6 +456,8 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
m.loadCacheFile()
|
m.loadCacheFile()
|
||||||
if len(m.sourceResults) > 0 {
|
if len(m.sourceResults) > 0 {
|
||||||
m.rebuildSources()
|
m.rebuildSources()
|
||||||
|
m.sources.Loading = true // reloading what the file had
|
||||||
|
m.sources.Age = m.cachedAge(m.oldestSources())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.placeDir != "" && opts.Open == nil {
|
if m.placeDir != "" && opts.Open == nil {
|
||||||
@@ -585,8 +587,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case persistedMsg:
|
case persistedMsg:
|
||||||
m.handlePersisted(msg)
|
m.handlePersisted(msg)
|
||||||
|
|
||||||
case groupsMsg:
|
case detailsMsg:
|
||||||
m.groups[msg.provider] = groupsState{names: msg.names, 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:
|
case crawlMsg:
|
||||||
cmds = append(cmds, m.handleCrawl(msg))
|
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 {
|
if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup {
|
||||||
m.focus = paneDetail // an account's row: nothing between
|
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):
|
case key.Matches(msg, k.PrevPane):
|
||||||
if m.focus == paneDetail {
|
if m.focus == paneDetail {
|
||||||
m.focus = m.lastLeft // back to what it describes
|
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
|
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():
|
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
|
||||||
// folded or unfolded a provider group
|
// 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):
|
case key.Matches(msg, k.Enter):
|
||||||
if m.focus < paneDetail {
|
if m.focus < paneDetail {
|
||||||
m.focus++
|
m.focus++
|
||||||
@@ -1032,6 +1049,7 @@ func (m *Model) refresh() tea.Cmd {
|
|||||||
case paneContainers:
|
case paneContainers:
|
||||||
if src, ok := m.selectedSource(); ok {
|
if src, ok := m.selectedSource(); ok {
|
||||||
m.containers.Refresh()
|
m.containers.Refresh()
|
||||||
|
m.details = map[string]detailsState{}
|
||||||
return m.loadContainers(src, m.selSource)
|
return m.loadContainers(src, m.selSource)
|
||||||
}
|
}
|
||||||
case paneSecrets:
|
case paneSecrets:
|
||||||
@@ -1154,6 +1172,7 @@ func (m *Model) showContainers(src provider.Source, key string) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.containers.Refresh()
|
m.containers.Refresh()
|
||||||
|
m.containers.Age = m.cachedAge(e.at)
|
||||||
return m.loadContainers(src, key)
|
return m.loadContainers(src, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1170,6 +1189,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.secrets.Refresh()
|
m.secrets.Refresh()
|
||||||
|
m.secrets.Age = m.cachedAge(e.at)
|
||||||
return m.loadObjects(c, m.objType, key)
|
return m.loadObjects(c, m.objType, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1299,6 +1319,26 @@ func (m *Model) detailStale() bool {
|
|||||||
return hasSecret || shown != m.containers.SelectedID()
|
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 {
|
func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
|
||||||
if msg.gen != m.sourcesGen {
|
if msg.gen != m.sourcesGen {
|
||||||
return nil
|
return nil
|
||||||
@@ -1738,6 +1778,14 @@ func (m Model) problemText(providerID, err string) string {
|
|||||||
return err
|
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
|
// loginFor is the sign-in L runs: the selected account's, if it needs
|
||||||
// one, else the last one asked for.
|
// one, else the last one asked for.
|
||||||
func (m Model) loginFor() *provider.AuthError {
|
func (m Model) loginFor() *provider.AuthError {
|
||||||
|
|||||||
+85
-26
@@ -758,7 +758,9 @@ func TestFocusKeys(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail},
|
{"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail},
|
||||||
{"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers},
|
{"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)
|
d.keys(tc.key)
|
||||||
if d.m.focus != tc.want {
|
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) {
|
func TestAccountPane(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t, grouped{described{fake.Azure(0)}})
|
d := newDriver(t, described{fake.Azure(0)})
|
||||||
d.keys("g", "tab") // the account's row, then straight to its pane
|
d.keys("g", "l") // the account's row, then its pane
|
||||||
if d.m.focus != paneDetail {
|
if d.m.focus != paneDetail {
|
||||||
t.Fatalf("focus %v, want the fourth pane", d.m.focus)
|
t.Fatalf("focus %v, want the fourth pane", d.m.focus)
|
||||||
}
|
}
|
||||||
scr := d.screen()
|
scr := d.screen()
|
||||||
for _, want := range []string{"Alice Example", "groups 2", "Platform team", "Vault readers"} {
|
if !strings.Contains(scr, "Alice Example") {
|
||||||
if !strings.Contains(scr, want) {
|
t.Errorf("account not shown:\n%s", scr)
|
||||||
t.Errorf("missing %q:\n%s", want, scr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
bar := lastLine(scr)
|
bar := lastLine(scr)
|
||||||
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
|
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" {
|
if got := d.clip().content; got != "oid-123" {
|
||||||
t.Errorf("y on the object ID copied %q", got)
|
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.
|
// / filters it, labels too.
|
||||||
d.keys("/")
|
d.keys("/")
|
||||||
d.typeText("vault")
|
d.typeText("object")
|
||||||
d.keys("enter")
|
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)
|
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")
|
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())
|
t.Errorf("filter not cleared:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
|
|
||||||
d.keys("shift+tab")
|
d.keys("h")
|
||||||
if d.m.focus != paneSources {
|
if d.m.focus != paneSources {
|
||||||
t.Errorf("back to %v, want Sources", d.m.focus)
|
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)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+62
-54
@@ -1,7 +1,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,18 +13,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The fourth pane shows Fields for anything but a secret. On a provider's
|
// 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
|
// row: its account, from what the provider knows locally.
|
||||||
// groups the account is in, loaded once a session when first shown.
|
|
||||||
|
|
||||||
type groupsState struct {
|
// A vault's, scope's or file's details beyond its listing load once a
|
||||||
names []string
|
// 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
|
err error
|
||||||
loading bool
|
loading bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type groupsMsg struct {
|
type detailsMsg struct {
|
||||||
provider string
|
req int
|
||||||
names []string
|
key string
|
||||||
|
attrs []provider.Attr
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +43,6 @@ func (m Model) detailOf() pane {
|
|||||||
// syncFields shows the Fields pane for what the focused pane has selected,
|
// 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
|
// 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.
|
// 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 {
|
func (m *Model) syncFields() tea.Cmd {
|
||||||
if m.detail.State() != panes.ValueHidden {
|
if m.detail.State() != panes.ValueHidden {
|
||||||
// A value revealed (or being fetched) from any pane stays in
|
// A value revealed (or being fetched) from any pane stays in
|
||||||
@@ -53,12 +54,14 @@ func (m *Model) syncFields() tea.Cmd {
|
|||||||
case paneSources:
|
case paneSources:
|
||||||
r, ok := m.sources.Selected()
|
r, ok := m.sources.Selected()
|
||||||
switch {
|
switch {
|
||||||
|
case m.nothingFound():
|
||||||
|
m.fields.Show("get started", m.getStartedFields())
|
||||||
case !ok:
|
case !ok:
|
||||||
m.fields.Show("", nil)
|
m.fields.Show("", nil)
|
||||||
case m.selectedProblem() != "":
|
case m.selectedProblem() != "":
|
||||||
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
||||||
case r.kind == rowGroup:
|
case r.kind == rowGroup:
|
||||||
return m.showAccount(r)
|
m.showAccount(r)
|
||||||
case r.kind == rowSource:
|
case r.kind == rowSource:
|
||||||
m.fields.Show(r.source.Name, m.sourceFields(r.source))
|
m.fields.Show(r.source.Name, m.sourceFields(r.source))
|
||||||
default:
|
default:
|
||||||
@@ -68,7 +71,9 @@ func (m *Model) syncFields() tea.Cmd {
|
|||||||
m.fields.Show("lazysecrets", m.statusFields())
|
m.fields.Show("lazysecrets", m.statusFields())
|
||||||
case paneContainers:
|
case paneContainers:
|
||||||
if c, ok := m.containers.Selected(); ok {
|
if c, ok := m.containers.Selected(); ok {
|
||||||
|
cmd := m.loadDetails(c)
|
||||||
m.fields.Show(c.Name, m.containerFields(c))
|
m.fields.Show(c.Name, m.containerFields(c))
|
||||||
|
return cmd
|
||||||
} else {
|
} else {
|
||||||
m.fields.Show("", nil)
|
m.fields.Show("", nil)
|
||||||
}
|
}
|
||||||
@@ -78,9 +83,8 @@ func (m *Model) syncFields() tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// showAccount shows a provider's account, loading its groups the first
|
// showAccount shows a provider's account.
|
||||||
// time.
|
func (m *Model) showAccount(r sourceRow) {
|
||||||
func (m *Model) showAccount(r sourceRow) tea.Cmd {
|
|
||||||
p := m.byID[r.provider]
|
p := m.byID[r.provider]
|
||||||
var fields []panes.Field
|
var fields []panes.Field
|
||||||
if a, ok := p.(provider.Accounts); ok {
|
if a, ok := p.(provider.Accounts); ok {
|
||||||
@@ -90,24 +94,11 @@ func (m *Model) showAccount(r sourceRow) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var cmd tea.Cmd
|
if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 {
|
||||||
if g, ok := p.(provider.AccountGroups); ok {
|
hint := emptyHint(p.Kind())
|
||||||
st, seen := m.groups[r.provider]
|
fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL})
|
||||||
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)...)
|
|
||||||
}
|
}
|
||||||
m.fields.Show(p.Label(), fields)
|
m.fields.Show(p.Label(), fields)
|
||||||
return cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceFields describe a subscription, workspace or repo.
|
// 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)))
|
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
|
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
|
// problemFields show why an account has nothing to show, wrapped to the
|
||||||
// pane, in red.
|
// pane, in red.
|
||||||
func (m Model) problemFields(problem string) []panes.Field {
|
func (m Model) problemFields(problem string) []panes.Field {
|
||||||
@@ -185,30 +215,8 @@ func (m Model) problemFields(problem string) []panes.Field {
|
|||||||
return out
|
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 {
|
func fieldName(f panes.Field) string {
|
||||||
if f.Label == "" || strings.HasPrefix(f.Label, "groups") {
|
return cmpOr(f.Label, f.Value)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
@@ -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://<workspace>"}
|
||||||
|
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"}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
|
|||||||
// Next run, with a backend too slow to answer during the test: the
|
// 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.
|
// lists show from the file, and the cursor is where it was.
|
||||||
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
|
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")
|
d2.keys("2")
|
||||||
if _, v, _ := d2.selected(); v != vault {
|
if _, v, _ := d2.selected(); v != vault {
|
||||||
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
|
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) {
|
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
file := filepath.Join(t.TempDir(), "cache.json")
|
file := filepath.Join(t.TempDir(), "cache.json")
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func (m Model) statusLine() string {
|
|||||||
failing++
|
failing++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if n := len(m.expiringItems()); n > 0 {
|
||||||
|
parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n)))
|
||||||
|
}
|
||||||
if accounts > 0 {
|
if accounts > 0 {
|
||||||
s := plural(accounts, "account")
|
s := plural(accounts, "account")
|
||||||
if failing > 0 {
|
if failing > 0 {
|
||||||
@@ -88,6 +91,8 @@ func (m Model) statusFields() []panes.Field {
|
|||||||
add(f)
|
add(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out = append(out, m.expiringFields()...)
|
||||||
|
|
||||||
for i, path := range m.configFiles {
|
for i, path := range m.configFiles {
|
||||||
label := ""
|
label := ""
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const (
|
|||||||
type Map struct {
|
type Map struct {
|
||||||
// lists
|
// lists
|
||||||
Up, Down, Top, Bottom key.Binding
|
Up, Down, Top, Bottom key.Binding
|
||||||
NextPane, PrevPane key.Binding
|
NextPane, PrevPane, Details key.Binding
|
||||||
Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding
|
Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||||
NextTab, PrevTab key.Binding
|
NextTab, PrevTab key.Binding
|
||||||
Enter, Back, Filter 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, "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, "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, "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, "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, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
|
||||||
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets 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{
|
var defaults = map[string][]string{
|
||||||
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
|
"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.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.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
|
||||||
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ type Fields struct {
|
|||||||
|
|
||||||
// Field is a row: a value under a label. Info rows (loading, errors)
|
// 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
|
// 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 {
|
type Field struct {
|
||||||
Label, Value string
|
Label, Value string
|
||||||
Info, Err bool
|
Info, Err bool
|
||||||
|
|
||||||
URL, Path, Provider string
|
URL, Path, Provider string
|
||||||
|
// Link is a lazysecrets link enter goes to, in the source of row Row.
|
||||||
|
Link, Row string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFields() Fields {
|
func NewFields() Fields {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ type List[T any] struct {
|
|||||||
Empty string // shown when loaded with no items
|
Empty string // shown when loaded with no items
|
||||||
Loading bool
|
Loading bool
|
||||||
Err error
|
Err error
|
||||||
|
// Age says how old the items are ("2h ago") while they reload, by
|
||||||
|
// the counter.
|
||||||
|
Age string
|
||||||
|
|
||||||
item Item[T]
|
item Item[T]
|
||||||
items []T
|
items []T
|
||||||
@@ -88,6 +91,7 @@ func (l *List[T]) SetError(err error) {
|
|||||||
func (l *List[T]) Refresh() {
|
func (l *List[T]) Refresh() {
|
||||||
l.Loading = true
|
l.Loading = true
|
||||||
l.Err = nil
|
l.Err = nil
|
||||||
|
l.Age = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *List[T]) SetLoading() {
|
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:
|
case l.Loading && len(l.items) == 0:
|
||||||
body = append(body, " "+spin+" loading…")
|
body = append(body, " "+spin+" loading…")
|
||||||
case l.Err != nil:
|
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))
|
body = append(body, st.Error.Render(" "+line))
|
||||||
}
|
}
|
||||||
case len(l.visible) == 0 && len(l.items) > 0:
|
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 {
|
if len(l.visible) > 0 {
|
||||||
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
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)
|
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))
|
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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user