feat: l for details; details for vaults, scopes and files; getting started; the age of cached lists (#17)
- `l` goes to the details of what is selected; `enter` still drills in - vaults, scopes and files show their details (vault settings from the listing, no extra requests) - with nothing to show, the right pane says how to get started - a list from the cache says how old it is while it reloads - what is expiring, in the status pane and `lazysecrets expiring` - when access is refused, what to check, for Azure and Databricks - no group list in an account's details, and no Microsoft Graph call for it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #17.
This commit is contained in:
1 parent
9b0329e9da
commit
09bacd8438
32 files changed
+1321
-254
No files matched your search
@@ -1,15 +1,10 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -160,68 +155,3 @@ func directoryRoles(wids any) string {
|
||||
slices.Sort(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// graphGroups lists the signed-in user's groups, nested ones too, from
|
||||
// Microsoft Graph with az's token for it.
|
||||
const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999"
|
||||
|
||||
var _ provider.AccountGroups = (*Provider)(nil)
|
||||
|
||||
// Groups lists the groups the account is a member of, by name.
|
||||
func (p *Provider) Groups(ctx context.Context) ([]string, error) {
|
||||
if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for next := graphGroups; next != ""; {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok.Token)
|
||||
resp, err := p.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page struct {
|
||||
Value []struct {
|
||||
Type string `json:"@odata.type"`
|
||||
Name string `json:"displayName"`
|
||||
} `json:"value"`
|
||||
Next string `json:"@odata.nextLink"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&page)
|
||||
resp.Body.Close()
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("listing groups from Microsoft Graph: %w", err)
|
||||
case page.Error != nil:
|
||||
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", page.Error.Message)
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", resp.Status)
|
||||
}
|
||||
for _, v := range page.Value {
|
||||
if v.Type == "#microsoft.graph.group" && v.Name != "" {
|
||||
names = append(names, v.Name)
|
||||
}
|
||||
}
|
||||
next = page.Next
|
||||
}
|
||||
slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// http is the client for Microsoft Graph; tests replace it.
|
||||
func (p *Provider) http() *http.Client {
|
||||
if p.graphClient != nil {
|
||||
return p.graphClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
@@ -75,40 +65,3 @@ func TestSignInMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type staticToken struct{}
|
||||
|
||||
func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
||||
return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
|
||||
type graphPages map[string]string // by URL
|
||||
|
||||
func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
body, ok := g[r.URL.String()]
|
||||
if !ok || r.Header.Get("Authorization") != "Bearer tok" {
|
||||
return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}
|
||||
|
||||
func TestGroups(t *testing.T) {
|
||||
next := "https://graph.microsoft.com/v1.0/next"
|
||||
p := &Provider{
|
||||
cred: staticToken{},
|
||||
account: &accountDetails{account: account{user: "alice@contoso.com"}},
|
||||
graphClient: &http.Client{Transport: graphPages{
|
||||
graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"},
|
||||
{"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`,
|
||||
next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`,
|
||||
}},
|
||||
}
|
||||
got, err := p.Groups(context.Background())
|
||||
if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) {
|
||||
t.Errorf("groups %q, %v", got, err)
|
||||
}
|
||||
p.account.cloud = "AzureChinaCloud"
|
||||
if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) {
|
||||
t.Errorf("other cloud: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -37,12 +36,11 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p
|
||||
type Provider struct {
|
||||
id, label string // "" for the single-account defaults
|
||||
|
||||
cred azcore.TokenCredential
|
||||
opts options
|
||||
account *accountDetails // nil for a service principal from AZURE_*
|
||||
graphClient *http.Client // for Groups; nil for the default
|
||||
subs *armsubscriptions.Client
|
||||
graph *armresourcegraph.Client
|
||||
cred azcore.TokenCredential
|
||||
opts options
|
||||
account *accountDetails // nil for a service principal from AZURE_*
|
||||
subs *armsubscriptions.Client
|
||||
graph *armresourcegraph.Client
|
||||
|
||||
mu sync.Mutex
|
||||
vaults map[string]*vaultClients // by vault URL
|
||||
@@ -185,10 +183,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 +222,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 +243,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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -174,8 +175,11 @@ func TestForbiddenIsShortened(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want {
|
||||
t.Errorf("error = %q, want %q", got, want)
|
||||
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\n\nCheck:\n• your role or group is activated in PIM\n• "; !strings.HasPrefix(got, want) {
|
||||
t.Errorf("error = %q, want %q…", got, want)
|
||||
}
|
||||
if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") {
|
||||
t.Errorf("firewall checks = %q", checks)
|
||||
}
|
||||
var re *azcore.ResponseError
|
||||
if !errors.As(err, &re) || re.StatusCode != 403 {
|
||||
@@ -183,6 +187,13 @@ func TestForbiddenIsShortened(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortenPolicyDenied(t *testing.T) {
|
||||
msg := "The user, group or application 'appid=04b07795;oid=1234;numgroups=83;iss=https://sts.windows.net/t/' does not have secrets list permission on key vault 'kv-app;location=norwayeast'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287"
|
||||
if got := shortenDenied(msg); got != "no secrets list permission in the vault's access policies" {
|
||||
t.Errorf("shortenDenied = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortenDenied(t *testing.T) {
|
||||
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
|
||||
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
|
||||
@@ -417,3 +428,49 @@ 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 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)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s\n got %q\nwant %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
@@ -279,13 +280,24 @@ func cleanError(err error) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
||||
se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
||||
if se.status == http.StatusForbidden {
|
||||
return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)}
|
||||
}
|
||||
return se
|
||||
}
|
||||
|
||||
var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`)
|
||||
|
||||
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
|
||||
// sentences plus caller and resource IDs, to its first sentence and the
|
||||
// missing action.
|
||||
func shortenDenied(msg string) string {
|
||||
// Access policies: "The user, group or application 'appid=…;oid=…'
|
||||
// does not have secrets list permission on key vault 'kv;location=…'".
|
||||
if m := policyDenied.FindStringSubmatch(msg); m != nil {
|
||||
return "no " + m[1] + " permission in the vault's access policies"
|
||||
}
|
||||
const marker = "Action: '"
|
||||
i := strings.Index(msg, marker)
|
||||
if i < 0 {
|
||||
@@ -317,4 +329,20 @@ func (e *serviceError) Error() string {
|
||||
return s
|
||||
}
|
||||
|
||||
// deniedChecks are what to check when Key Vault refuses: the network,
|
||||
// for its firewall's refusals, else access.
|
||||
func deniedChecks(msg string) []string {
|
||||
if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") ||
|
||||
strings.Contains(msg, "private link") {
|
||||
return []string{
|
||||
"you're on a network the vault lets in (VPN, office)",
|
||||
"the vault's owner has let your network in",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"your role or group is activated in PIM",
|
||||
"the vault's owner has given you access: a Key Vault role, or its access policies",
|
||||
}
|
||||
}
|
||||
|
||||
func (e *serviceError) Unwrap() error { return e.err }
|
||||
@@ -0,0 +1,158 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
}
|
||||
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(v.rbac, false) {
|
||||
add("access", "Azure roles (RBAC)")
|
||||
} else {
|
||||
add("access", fmt.Sprintf("access policies (%d)", v.policies))
|
||||
}
|
||||
if on(v.softDelete, true) {
|
||||
days := int32(90)
|
||||
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(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")
|
||||
}
|
||||
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)
|
||||
for i, k := range keys {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "tags"
|
||||
}
|
||||
out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// on is *b, or def when it isn't set.
|
||||
func on(b *bool, def bool) bool {
|
||||
if b == nil {
|
||||
return def
|
||||
}
|
||||
return *b
|
||||
}
|
||||
@@ -317,15 +317,52 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// deniedChecks are what to check when Databricks refuses: the network,
|
||||
// when the workspace's IP access list refused, else access, and for a
|
||||
// profile signed in through az, which account az is on.
|
||||
func deniedChecks(pr profile, msg string) []string {
|
||||
if strings.Contains(msg, "IP ACL") || strings.Contains(msg, "IP access list") {
|
||||
return []string{
|
||||
"you're on a network the workspace lets in (VPN, office)",
|
||||
"the workspace's admin has let your network in",
|
||||
}
|
||||
}
|
||||
var checks []string
|
||||
if pr.authType == "azure-cli" {
|
||||
checks = append(checks, "az is signed in as the account with access: profile "+pr.name+" uses your az login (az account show)")
|
||||
}
|
||||
return append(checks,
|
||||
"you have access to the workspace",
|
||||
"the scope's manager has given you READ on it, or a group you're in",
|
||||
)
|
||||
}
|
||||
|
||||
var errNoFallback = errors.New("no Key Vault fallback")
|
||||
|
||||
// combine reports both the Databricks and the Key Vault failure. An auth
|
||||
// error from either stays reachable with errors.As.
|
||||
// combine reports both the Databricks and the Key Vault failure, with
|
||||
// both their checks after. An auth error from either stays reachable
|
||||
// with errors.As.
|
||||
func combine(dbxErr, kvErr error) error {
|
||||
if errors.Is(kvErr, errNoFallback) {
|
||||
return dbxErr
|
||||
}
|
||||
return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr)
|
||||
var checks []string
|
||||
strip := func(err error) error {
|
||||
if d, ok := err.(*provider.Denied); ok {
|
||||
for _, c := range d.Checks {
|
||||
if !slices.Contains(checks, c) {
|
||||
checks = append(checks, c)
|
||||
}
|
||||
}
|
||||
return d.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
err := fmt.Errorf("%w; Key Vault fallback: %w", strip(dbxErr), strip(kvErr))
|
||||
if len(checks) > 0 {
|
||||
return &provider.Denied{Err: err, Checks: checks}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// classify turns authentication failures into *provider.AuthError: a 401
|
||||
@@ -336,6 +373,9 @@ func classify(pr profile, err error) error {
|
||||
}
|
||||
var api *apierr.APIError
|
||||
if errors.As(err, &api) {
|
||||
if api.StatusCode == 403 {
|
||||
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
|
||||
}
|
||||
if api.StatusCode != 401 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -140,6 +140,15 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server {
|
||||
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||
map[string]any{"key": "client-secret"},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"items": []any{
|
||||
map[string]any{"principal": "users", "permission": "READ"},
|
||||
map[string]any{"principal": "admins", "permission": "MANAGE"},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked":
|
||||
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"})
|
||||
case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "locked":
|
||||
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have MANAGE permission"})
|
||||
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"key": q.Get("key"),
|
||||
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
|
||||
@@ -291,3 +300,44 @@ func TestClassify(t *testing.T) {
|
||||
t.Error("cancellation classified as auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeDetails(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||
src := provider.Source{ID: "prod"}
|
||||
attrs, err := p.Details(context.Background(), provider.Container{Source: src, Name: "app"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []provider.Attr{{Label: "access", Value: "admins · manage"}, {Value: "users · read"}}
|
||||
if !reflect.DeepEqual(attrs, want) {
|
||||
t.Errorf("app: %+v", attrs)
|
||||
}
|
||||
attrs, err = p.Details(context.Background(), provider.Container{Source: src, Name: "locked"})
|
||||
if err != nil || len(attrs) != 1 || !strings.Contains(attrs[0].Value, "managers") {
|
||||
t.Errorf("locked: %+v, %v", attrs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedSaysWhatToCheck(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||
_, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret)
|
||||
var d *provider.Denied
|
||||
// The test profile signs in through az: that's the first check.
|
||||
if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• az is signed in as the account with access: profile prod uses your az login") ||
|
||||
!strings.Contains(err.Error(), "\n• you have access to the workspace") {
|
||||
t.Errorf("err %q", err)
|
||||
}
|
||||
if got := deniedChecks(profile{authType: "pat"}, "denied"); strings.Contains(got[0], "az") {
|
||||
t.Errorf("a token profile's checks %q", got)
|
||||
}
|
||||
if got := deniedChecks(profile{}, "Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") {
|
||||
t.Errorf("IP ACL checks %q", got)
|
||||
}
|
||||
|
||||
// Both refused: one message, then both lists of checks.
|
||||
err = combine(&provider.Denied{Err: errors.New("dbx"), Checks: []string{"a", "b"}},
|
||||
&provider.Denied{Err: errors.New("kv"), Checks: []string{"b", "c"}})
|
||||
if got := err.Error(); got != "dbx; Key Vault fallback: kv\n\nCheck:\n• a\n• b\n• c" {
|
||||
t.Errorf("combined %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package databricks
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/databricks/databricks-sdk-go/apierr"
|
||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Details are who has access to a scope, and how much. Only the scope's
|
||||
// managers may see that; to others it says so.
|
||||
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
|
||||
w, pr, err := p.client(c.Source.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acls, err := w.ListAclsAll(ctx, workspace.ListAclsRequest{Scope: c.Name})
|
||||
var api *apierr.APIError
|
||||
switch {
|
||||
case errors.As(err, &api) && api.StatusCode == 403:
|
||||
return []provider.Attr{{Label: "access", Value: "only the scope's managers can see who has it"}}, nil
|
||||
case err != nil:
|
||||
return nil, classify(pr, err)
|
||||
case len(acls) == 0:
|
||||
return []provider.Attr{{Label: "access", Value: "workspace admins only"}}, nil
|
||||
}
|
||||
rank := map[workspace.AclPermission]int{workspace.AclPermissionManage: 0, workspace.AclPermissionWrite: 1, workspace.AclPermissionRead: 2}
|
||||
slices.SortFunc(acls, func(a, b workspace.AclItem) int {
|
||||
return cmp.Or(cmp.Compare(rank[a.Permission], rank[b.Permission]), cmp.Compare(a.Principal, b.Principal))
|
||||
})
|
||||
out := make([]provider.Attr, len(acls))
|
||||
for i, a := range acls {
|
||||
out[i] = provider.Attr{Value: a.Principal + " · " + strings.ToLower(string(a.Permission))}
|
||||
}
|
||||
out[0].Label = "access"
|
||||
return out, nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -93,6 +94,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.
|
||||
@@ -107,6 +110,7 @@ func (c Container) ObjectTypes() []ObjectType {
|
||||
type Attr struct {
|
||||
Label string
|
||||
Value string
|
||||
Warn bool // worth a look (a plain .env tracked by git): shown in red
|
||||
}
|
||||
|
||||
// Object describes a secret, key or certificate without its value.
|
||||
@@ -199,9 +203,27 @@ type Account struct {
|
||||
Copy, CopyLabel string
|
||||
}
|
||||
|
||||
// AccountGroups is implemented by providers that can list the groups
|
||||
// their account is a member of, directly or through other groups. It
|
||||
// goes over the network.
|
||||
type AccountGroups interface {
|
||||
Groups(ctx context.Context) ([]string, error)
|
||||
// Denied is a refusal with what to check, from what the service said: a
|
||||
// role to activate in PIM, access to ask for, the network. Error is the
|
||||
// refusal, then the checks as a list.
|
||||
type Denied struct {
|
||||
Err error
|
||||
Checks []string
|
||||
}
|
||||
|
||||
func (d *Denied) Error() string {
|
||||
if len(d.Checks) == 0 {
|
||||
return d.Err.Error()
|
||||
}
|
||||
return d.Err.Error() + "\n\nCheck:\n• " + strings.Join(d.Checks, "\n• ")
|
||||
}
|
||||
|
||||
func (d *Denied) Unwrap() error { return d.Err }
|
||||
|
||||
// ContainerDetails is implemented by providers that can say more about a
|
||||
// container than its listing does: a vault's settings, who has access to
|
||||
// a scope, how a file is encrypted and what git knows of it. It may go
|
||||
// over the network; ErrUnsupported means there's nothing more for c.
|
||||
type ContainerDetails interface {
|
||||
Details(ctx context.Context, c Container) ([]Attr, error)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Details are how a file is encrypted and for whom, and what git knows of
|
||||
// it: a plain .env file that git tracks, or could, is marked.
|
||||
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
|
||||
r, ok := p.repo(c.Source.ID)
|
||||
if !ok {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
rel, err := filepath.Rel(r.root, c.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Attr
|
||||
meta, isSops := readMeta(c.ID)
|
||||
if isSops {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
|
||||
for i, k := range meta.recipients {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "keys"
|
||||
}
|
||||
out = append(out, provider.Attr{Label: label, Value: k})
|
||||
}
|
||||
if !meta.modified.IsZero() {
|
||||
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
|
||||
}
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
|
||||
}
|
||||
|
||||
git := func(args ...string) (string, bool) {
|
||||
b, err := exec.CommandContext(ctx, "git", append([]string{"-C", r.root}, args...)...).Output()
|
||||
return strings.TrimSpace(string(b)), err == nil
|
||||
}
|
||||
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
|
||||
case tracked && !isSops:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||
case tracked:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
|
||||
default:
|
||||
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if last, ok := git("log", "-1", "--format=%h · %ar · %an: %s", "--", rel); ok && last != "" {
|
||||
out = append(out, provider.Attr{Label: "last commit", Value: last})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -815,8 +815,30 @@ func anyMatch(res []*regexp.Regexp, f string) bool {
|
||||
// --- sops files ---
|
||||
|
||||
type sopsMeta struct {
|
||||
methods []string // age, azure_kv, …
|
||||
modified time.Time
|
||||
methods []string // age, azure_kv, …
|
||||
recipients []string // "age age1…", "azure_kv https://kv…/keys/sops"
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
// recipient names a key a file is encrypted for, from its sops metadata:
|
||||
// public, never a secret.
|
||||
func recipient(method string, f map[string]string) string {
|
||||
var v string
|
||||
switch method {
|
||||
case "age":
|
||||
v = f["recipient"]
|
||||
case "pgp":
|
||||
v = f["fp"]
|
||||
case "kms":
|
||||
v = f["arn"]
|
||||
case "gcp_kms":
|
||||
v = f["resource_id"]
|
||||
case "azure_kv":
|
||||
v = strings.TrimSuffix(f["vault_url"], "/") + "/keys/" + f["name"]
|
||||
case "hc_vault":
|
||||
v = strings.TrimSuffix(f["vault_address"], "/") + " " + f["engine_path"] + "/" + f["key_name"]
|
||||
}
|
||||
return method + " " + v
|
||||
}
|
||||
|
||||
const maxFileSize = 4 << 20
|
||||
@@ -848,6 +870,15 @@ func readMeta(file string) (sopsMeta, bool) {
|
||||
for _, k := range known {
|
||||
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
|
||||
m.methods = append(m.methods, k)
|
||||
for _, e := range v {
|
||||
fields := map[string]string{}
|
||||
if em, ok := e.(map[string]any); ok {
|
||||
for fk, fv := range em {
|
||||
fields[fk] = fmt.Sprint(fv)
|
||||
}
|
||||
}
|
||||
m.recipients = append(m.recipients, recipient(k, fields))
|
||||
}
|
||||
}
|
||||
}
|
||||
if s, ok := doc.Sops["lastmodified"].(string); ok {
|
||||
@@ -864,6 +895,7 @@ func readMeta(file string) (sopsMeta, bool) {
|
||||
for _, k := range known {
|
||||
if strings.Contains(text, "sops_"+k+"__") {
|
||||
m.methods = append(m.methods, k)
|
||||
m.recipients = append(m.recipients, envRecipients(text, k)...)
|
||||
}
|
||||
}
|
||||
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
|
||||
@@ -881,6 +913,35 @@ func readMeta(file string) (sopsMeta, bool) {
|
||||
return m, true
|
||||
}
|
||||
|
||||
// envRecipients are method's keys in a dotenv or ini sops file, whose
|
||||
// metadata is flattened: sops_age__list_0__map_recipient=age1….
|
||||
func envRecipients(text, method string) []string {
|
||||
prefix := "sops_" + method + "__list_"
|
||||
var order []string
|
||||
keys := map[string]map[string]string{}
|
||||
for line := range strings.Lines(text) {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
rest, found := strings.CutPrefix(strings.TrimSpace(k), prefix)
|
||||
if !ok || !found {
|
||||
continue
|
||||
}
|
||||
n, field, ok := strings.Cut(rest, "__map_")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if keys[n] == nil {
|
||||
keys[n] = map[string]string{}
|
||||
order = append(order, n)
|
||||
}
|
||||
keys[n][field] = strings.TrimSpace(v)
|
||||
}
|
||||
out := make([]string, len(order))
|
||||
for i, n := range order {
|
||||
out[i] = recipient(method, keys[n])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatOf(file string) string {
|
||||
switch strings.ToLower(filepath.Ext(file)) {
|
||||
case ".yaml", ".yml":
|
||||
|
||||
@@ -477,3 +477,47 @@ func TestFile(t *testing.T) {
|
||||
t.Errorf("lines %v\nwant %v", lines, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDetails(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
"prod.env": sopsEnv,
|
||||
"tracked.env": "TOKEN=x\n", // a plain .env in git: worth a look
|
||||
})
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=x\n"), 0o600)
|
||||
os.WriteFile(filepath.Join(root, "local.env"), []byte("TOKEN=x\n"), 0o600)
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
details := func(name string) map[string]provider.Attr {
|
||||
t.Helper()
|
||||
attrs, err := p.Details(ctx, provider.Container{Source: srcs[0], ID: filepath.Join(root, name), Name: name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]provider.Attr{}
|
||||
for _, a := range attrs {
|
||||
out[a.Label] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
d := details("secrets/app.yaml")
|
||||
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
|
||||
!strings.Contains(d["last commit"].Value, "t: files") || d["encrypted"].Value == "" {
|
||||
t.Errorf("sops yaml: %+v", d)
|
||||
}
|
||||
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
|
||||
t.Errorf("sops env: %+v", d)
|
||||
}
|
||||
if d := details("tracked.env"); d["encryption"].Value != "none: plain text" || !d["git"].Warn {
|
||||
t.Errorf("tracked plain env: %+v", d)
|
||||
}
|
||||
if d := details(".env"); d["git"].Value != "ignored" || d["git"].Warn {
|
||||
t.Errorf("ignored env: %+v", d)
|
||||
}
|
||||
if d := details("local.env"); !d["git"].Warn || !strings.Contains(d["git"].Value, "not ignored") {
|
||||
t.Errorf("untracked env: %+v", d)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user