feat: vaults, scopes and files show their details: a vault's settings, who has a scope, a file's keys and git status
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9b0329e9da
commit
5381cf5d43
14 files changed
+474
-2
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user