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