diff --git a/docs/azure.md b/docs/azure.md index 5c0c423..8488ee8 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -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. 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 fc21f3c..e9497cd 100644 --- a/internal/provider/azure/azure_test.go +++ b/internal/provider/azure/azure_test.go @@ -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) - } - var got []string - for _, a := range attrs { - got = append(got, a.Label+"="+a.Value) - } +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"} - if !slices.Equal(got, want) { - t.Errorf("details\n got %q\nwant %q", got, want) + 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) + } } - 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) } diff --git a/internal/provider/azure/details.go b/internal/provider/azure/details.go index 89cb630..714005c 100644 --- a/internal/provider/azure/details.go +++ b/internal/provider/azure/details.go @@ -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 { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 3affae6..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. diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index f40c162..12f119d 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -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})