feat: a vault's settings come with the vault list, with no requests of their own
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5381cf5d43
commit
879b961ac5
6 files changed
+156
-93
No files matched your search
+5
-6
@@ -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.
|
|
||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)",
|
||||||
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
"soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)",
|
||||||
if r.URL.Path != id {
|
"private=1 endpoint", "tags=env: prod", "=team: platform"}
|
||||||
http.NotFound(w, r)
|
check := func(name string, attrs []provider.Attr) {
|
||||||
return
|
t.Helper()
|
||||||
}
|
|
||||||
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
|
var got []string
|
||||||
for _, a := range attrs {
|
for _, a := range attrs {
|
||||||
got = append(got, a.Label+"="+a.Value)
|
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) {
|
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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
Reference in new issue
Block a user