feat: a vault's settings come with the vault list, with no requests of their own
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 2s

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:42:48 +02:00
1 parent 5381cf5d43
commit 879b961ac5
6 files changed
+155 -92

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
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
On a vault, the right pane shows its settings from the Key Vault
management API: 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 load the first time you're
on the vault (`r` in the Vaults pane loads them again), and need read
access to the vault resource, as discovering it does.
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.
+2 -6
View File
@@ -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(),
})
}
}
+37 -26
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:]
}
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)
}
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)
}
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)
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
import (
"context"
"fmt"
"slices"
"strings"
@@ -12,66 +11,131 @@ import (
"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
// 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
}
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
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))
}
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(pr.EnableRbacAuthorization, false) {
if on(v.rbac, false) {
add("access", "Azure roles (RBAC)")
} 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)
if pr.SoftDeleteRetentionInDays != nil {
days = *pr.SoftDeleteRetentionInDays
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(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]))
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")
}
keys := make([]string, 0, len(v.Tags))
for k := range v.Tags {
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)
@@ -80,23 +144,11 @@ func vaultAttrs(v armkeyvault.Vault) []provider.Attr {
if i == 0 {
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
}
// 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 {
+2
View File
@@ -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.
+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)))
}
}
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})