feat: l for details; details for vaults, scopes and files; getting started; the age of cached lists #17

Merged
keb merged 17 commits from feat/l-for-details into main 2026-10-11 14:24:09 +02:00
6 changed files with 157 additions and 94 deletions
Showing only changes of commit 879b961ac5 - Show all commits

No files matched your search

+5 -6
View File
@@ -65,9 +65,8 @@ key (PEM public key) or certificate; private key material is never
fetched. `v` lists an item's versions. Items expiring within 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 `✗`.
On a vault, the right pane shows its settings from the Key Vault On a vault, the right pane shows its settings: resource group, location,
management API: resource group, location, SKU, whether access is by Azure SKU, whether access is by Azure roles or access policies, soft delete and
roles or access policies, soft delete and purge protection, which networks purge protection, which networks can reach it, private endpoints and tags.
can reach it, private endpoints and tags. They load the first time you're They come with the list of vaults, so they cost no extra requests and are
on the vault (`r` in the Vaults pane loads them again), and need read cached with it.
access to the vault resource, as discovering it does.
+2 -6
View File
@@ -185,10 +185,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 +224,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 +245,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(),
}) })
} }
} }
+39 -28
View File
@@ -419,37 +419,48 @@ func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
return b[1 : 1+size], b[1+size:] return b[1 : 1+size], b[1+size:]
} }
func TestVaultDetails(t *testing.T) { func TestVaultSettings(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)", 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)", "soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)",
"private=1 endpoint", "tags=env: prod", "=team: platform"} "private=1 endpoint", "tags=env: prod", "=team: platform"}
if !slices.Equal(got, want) { check := func(name string, attrs []provider.Attr) {
t.Errorf("details\n got %q\nwant %q", got, want) t.Helper()
var got []string
for _, a := range attrs {
got = append(got, a.Label+"="+a.Value)
}
if !slices.Equal(got, want) {
t.Errorf("%s\n got %q\nwant %q", name, got, want)
}
} }
if _, err := p.Details(context.Background(), provider.Container{ID: "https://kv.vault.azure.net/"}); !errors.Is(err, provider.ErrUnsupported) {
t.Errorf("pinned vault: %v", err) // Resource Graph: the query's columns, as JSON gives them.
c, ok := graphVault(provider.Source{ID: "s"}, map[string]any{
"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/", "location": "norwayeast",
"resourceGroup": "rg-sec", "tags": map[string]any{"team": "platform", "env": "prod"},
"sku": "premium", "rbac": true, "softDelete": nil, "retention": 30.0, "purge": true,
"publicAccess": "", "defaultAction": "Deny", "ipRules": 1.0, "subnets": 0.0, "endpoints": 1.0, "policies": 0.0,
})
if !ok {
t.Fatal("row not read")
} }
check("resource graph", c.Attrs)
// The management API, when Resource Graph fails.
id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv"
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": id, "name": "kv", "location": "norwayeast",
"tags": map[string]any{"team": "platform", "env": "prod"},
"properties": map[string]any{
"tenantId": "t", "vaultUri": "https://kv.vault.azure.net/", "sku": map[string]any{"family": "A", "name": "premium"},
"enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30,
"networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}},
"privateEndpointConnections": []any{map[string]any{"id": "pe1"}},
}}}})
})
vaults, err := p.armVaults(context.Background(), provider.Source{ID: "s"})
if err != nil || len(vaults) != 1 {
t.Fatalf("arm: %v %v", vaults, err)
}
check("management API", vaults[0].Attrs)
} }
+106 -54
View File
@@ -1,7 +1,6 @@
package azure package azure
import ( import (
"context"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
@@ -12,66 +11,131 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
// Details are a vault's settings, from the Key Vault management API: where // A vault's settings come with the listing, from Resource Graph or the
// it is, how access to it works, and how it's protected. Pinned vaults, // Key Vault management API, with no requests of their own: where it is,
// known only by their URL, have none. // how access to it works, and how it's protected.
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
id, err := arm.ParseResourceID(c.ID) type vaultInfo struct {
if err != nil { group, location, sku string
return nil, provider.ErrUnsupported rbac, softDelete, purge *bool
} retention *int32
client, err := armkeyvault.NewVaultsClient(id.SubscriptionID, p.cred, p.opts.arm) publicAccess, defaultAction string
if err != nil { ipRules, subnets, endpoints int
return nil, err policies int
} tags map[string]string
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 { // 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 var out []provider.Attr
add := func(label, value string) { add := func(label, value string) {
if value != "" { if value != "" {
out = append(out, provider.Attr{Label: label, Value: value}) out = append(out, provider.Attr{Label: label, Value: value})
} }
} }
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil { add("group", v.group)
add("group", id.ResourceGroupName) add("location", v.location)
} add("SKU", v.sku)
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 // Who may read secrets is decided by Azure roles or by the vault's
// own access policies, never both. // own access policies, never both.
if on(pr.EnableRbacAuthorization, false) { if on(v.rbac, false) {
add("access", "Azure roles (RBAC)") add("access", "Azure roles (RBAC)")
} else { } else {
add("access", fmt.Sprintf("access policies (%d)", len(pr.AccessPolicies))) add("access", fmt.Sprintf("access policies (%d)", v.policies))
} }
if on(pr.EnableSoftDelete, true) { if on(v.softDelete, true) {
days := int32(90) days := int32(90)
if pr.SoftDeleteRetentionInDays != nil { if v.retention != nil {
days = *pr.SoftDeleteRetentionInDays days = *v.retention
} }
add("soft delete", fmt.Sprintf("on, %d days", days)) add("soft delete", fmt.Sprintf("on, %d days", days))
} else { } else {
add("soft delete", "off") add("soft delete", "off")
} }
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(pr.EnablePurgeProtection, false)]) add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
add("network", network(pr)) switch {
if n := len(pr.PrivateEndpointConnections); n > 0 { case strings.EqualFold(v.publicAccess, "disabled"):
add("private", fmt.Sprintf("%d endpoint%s", n, map[bool]string{true: "s"}[n > 1])) add("network", "private endpoints only")
case strings.EqualFold(v.defaultAction, "deny"):
add("network", fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", v.ipRules, v.subnets))
default:
add("network", "all networks")
} }
keys := make([]string, 0, len(v.Tags)) if v.endpoints > 0 {
for k := range v.Tags { 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) keys = append(keys, k)
} }
slices.Sort(keys) slices.Sort(keys)
@@ -80,23 +144,11 @@ func vaultAttrs(v armkeyvault.Vault) []provider.Attr {
if i == 0 { if i == 0 {
label = "tags" label = "tags"
} }
out = append(out, provider.Attr{Label: label, Value: k + ": " + deref(v.Tags[k])}) out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]})
} }
return out 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. // on is *b, or def when it isn't set.
func on(b *bool, def bool) bool { func on(b *bool, def bool) bool {
if b == nil { if b == nil {
+2
View File
@@ -93,6 +93,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.
+3
View File
@@ -187,6 +187,9 @@ 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)]; { switch st := m.details[containerKey(c)]; {
case st.loading: case st.loading:
out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true}) out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true})