feat: vaults, scopes and files show their details: a vault's settings, who has a scope, a file's keys and git status
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 1s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-11 13:41:12 +02:00
1 parent 9b0329e9da
commit 5381cf5d43
14 files changed
+474 -2

No files matched your search

+36
View File
@@ -19,6 +19,7 @@ import (
"net/http"
"net/http/httptest"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -417,3 +418,38 @@ func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
size := (len(b) - 1) / 2
return b[1 : 1+size], b[1+size:]
}
func TestVaultDetails(t *testing.T) {
id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv"
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != id {
http.NotFound(w, r)
return
}
writeJSON(w, 200, map[string]any{"id": id, "name": "kv", "location": "norwayeast",
"tags": map[string]any{"team": "platform", "env": "prod"},
"properties": map[string]any{
"tenantId": "t", "sku": map[string]any{"family": "A", "name": "premium"},
"enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30,
"networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}},
"privateEndpointConnections": []any{map[string]any{"id": "pe1"}},
}})
})
attrs, err := p.Details(context.Background(), provider.Container{ID: id})
if err != nil {
t.Fatal(err)
}
var got []string
for _, a := range attrs {
got = append(got, a.Label+"="+a.Value)
}
want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)",
"soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)",
"private=1 endpoint", "tags=env: prod", "=team: platform"}
if !slices.Equal(got, want) {
t.Errorf("details\n got %q\nwant %q", got, want)
}
if _, err := p.Details(context.Background(), provider.Container{ID: "https://kv.vault.azure.net/"}); !errors.Is(err, provider.ErrUnsupported) {
t.Errorf("pinned vault: %v", err)
}
}
+106
View File
@@ -0,0 +1,106 @@
package azure
import (
"context"
"fmt"
"slices"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Details are a vault's settings, from the Key Vault management API: where
// it is, how access to it works, and how it's protected. Pinned vaults,
// known only by their URL, have none.
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
id, err := arm.ParseResourceID(c.ID)
if err != nil {
return nil, provider.ErrUnsupported
}
client, err := armkeyvault.NewVaultsClient(id.SubscriptionID, p.cred, p.opts.arm)
if err != nil {
return nil, err
}
resp, err := client.Get(ctx, id.ResourceGroupName, id.Name, nil)
if err != nil {
return nil, cleanError(err)
}
return vaultAttrs(resp.Vault), nil
}
func vaultAttrs(v armkeyvault.Vault) []provider.Attr {
var out []provider.Attr
add := func(label, value string) {
if value != "" {
out = append(out, provider.Attr{Label: label, Value: value})
}
}
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
add("group", id.ResourceGroupName)
}
add("location", deref(v.Location))
pr := v.Properties
if pr == nil {
return out
}
if pr.SKU != nil && pr.SKU.Name != nil {
add("SKU", string(*pr.SKU.Name))
}
// Who may read secrets is decided by Azure roles or by the vault's
// own access policies, never both.
if on(pr.EnableRbacAuthorization, false) {
add("access", "Azure roles (RBAC)")
} else {
add("access", fmt.Sprintf("access policies (%d)", len(pr.AccessPolicies)))
}
if on(pr.EnableSoftDelete, true) {
days := int32(90)
if pr.SoftDeleteRetentionInDays != nil {
days = *pr.SoftDeleteRetentionInDays
}
add("soft delete", fmt.Sprintf("on, %d days", days))
} else {
add("soft delete", "off")
}
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(pr.EnablePurgeProtection, false)])
add("network", network(pr))
if n := len(pr.PrivateEndpointConnections); n > 0 {
add("private", fmt.Sprintf("%d endpoint%s", n, map[bool]string{true: "s"}[n > 1]))
}
keys := make([]string, 0, len(v.Tags))
for k := range v.Tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := ""
if i == 0 {
label = "tags"
}
out = append(out, provider.Attr{Label: label, Value: k + ": " + deref(v.Tags[k])})
}
return out
}
// network is where the vault can be reached from.
func network(pr *armkeyvault.VaultProperties) string {
if strings.EqualFold(deref(pr.PublicNetworkAccess), "disabled") {
return "private endpoints only"
}
acl := pr.NetworkACLs
if acl == nil || acl.DefaultAction == nil || *acl.DefaultAction != armkeyvault.NetworkRuleActionDeny {
return "all networks"
}
return fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", len(acl.IPRules), len(acl.VirtualNetworkRules))
}
// on is *b, or def when it isn't set.
func on(b *bool, def bool) bool {
if b == nil {
return def
}
return *b
}
@@ -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)
}
}
+43
View File
@@ -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
}
+9
View File
@@ -107,6 +107,7 @@ func (c Container) ObjectTypes() []ObjectType {
type Attr struct {
Label string
Value string
Warn bool // worth a look (a plain .env tracked by git): shown in red
}
// Object describes a secret, key or certificate without its value.
@@ -199,6 +200,14 @@ type Account struct {
Copy, CopyLabel string
}
// ContainerDetails is implemented by providers that can say more about a
// container than its listing does: a vault's settings, who has access to
// a scope, how a file is encrypted and what git knows of it. It may go
// over the network; ErrUnsupported means there's nothing more for c.
type ContainerDetails interface {
Details(ctx context.Context, c Container) ([]Attr, error)
}
// AccountGroups is implemented by providers that can list the groups
// their account is a member of, directly or through other groups. It
// goes over the network.
+62
View File
@@ -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
}
+63 -2
View File
@@ -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":
+44
View File
@@ -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)
}
}
+6
View File
@@ -177,6 +177,7 @@ type Model struct {
fields panes.Fields
lastLeft pane
groups map[string]groupsState
details map[string]detailsState // by containerKey
getenv func(string) string
nvimOpen func(server, path string, line int) error // edit.go
now func() time.Time
@@ -376,6 +377,7 @@ func New(providers []provider.Provider, opts Options) Model {
nvimOpen: nvimOpen,
cache: newCache(),
groups: map[string]groupsState{},
details: map[string]detailsState{},
fields: panes.NewFields(),
}
if m.clip == nil {
@@ -588,6 +590,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case groupsMsg:
m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err}
case detailsMsg:
m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err}
case crawlMsg:
cmds = append(cmds, m.handleCrawl(msg))
@@ -1032,6 +1037,7 @@ func (m *Model) refresh() tea.Cmd {
case paneContainers:
if src, ok := m.selectedSource(); ok {
m.containers.Refresh()
m.details = map[string]detailsState{}
return m.loadContainers(src, m.selSource)
}
case paneSecrets:
+19
View File
@@ -1236,3 +1236,22 @@ func TestStatusPane(t *testing.T) {
t.Errorf("back to %v", d.m.focus)
}
}
// detailed is an account whose vaults have details.
type detailed struct{ provider.Provider }
func (detailed) Details(_ context.Context, c provider.Container) ([]provider.Attr, error) {
return []provider.Attr{{Label: "access", Value: "Azure roles (RBAC)"}, {Label: "git", Value: "tracked", Warn: true}}, nil
}
func TestContainerDetails(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, detailed{fake.Azure(0)})
d.keys("2")
if scr := d.screen(); !strings.Contains(scr, "access Azure roles (RBAC)") || !strings.Contains(scr, "URI") {
t.Errorf("details:\n%s", scr)
}
if st := d.m.details[d.m.containers.SelectedID()]; st.loading || len(st.attrs) != 2 {
t.Errorf("state %+v", st)
}
}
+44
View File
@@ -29,6 +29,20 @@ type groupsMsg struct {
err error
}
// A vault's, scope's or file's details beyond its listing load once a
// session, the first time it's shown, and again with refresh.
type detailsState struct {
attrs []provider.Attr
err error
loading bool
}
type detailsMsg struct {
key string
attrs []provider.Attr
err error
}
// detailOf is the pane the fourth pane describes: the one in focus, or,
// with the fourth in focus, the one it was entered from.
func (m Model) detailOf() pane {
@@ -68,7 +82,9 @@ func (m *Model) syncFields() tea.Cmd {
m.fields.Show("lazysecrets", m.statusFields())
case paneContainers:
if c, ok := m.containers.Selected(); ok {
cmd := m.loadDetails(c)
m.fields.Show(c.Name, m.containerFields(c))
return cmd
} else {
m.fields.Show("", nil)
}
@@ -171,9 +187,37 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
}
}
switch st := m.details[containerKey(c)]; {
case st.loading:
out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true})
case errors.Is(st.err, provider.ErrUnsupported):
case st.err != nil:
out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true})
}
for _, a := range m.details[containerKey(c)].attrs {
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
}
return out
}
// loadDetails loads c's details, if its provider has them and they
// aren't loaded yet.
func (m *Model) loadDetails(c provider.Container) tea.Cmd {
d, ok := m.providerFor(c.Source).(provider.ContainerDetails)
key := containerKey(c)
if _, seen := m.details[key]; !ok || seen {
return nil
}
m.details[key] = detailsState{loading: true}
timeout := m.timeout
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
attrs, err := d.Details(ctx, c)
return detailsMsg{key: key, attrs: attrs, err: err}
}
}
// problemFields show why an account has nothing to show, wrapped to the
// pane, in red.
func (m Model) problemFields(problem string) []panes.Field {