Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f64ec47d71
134 files changed
+27239
No files matched your search
@@ -0,0 +1,407 @@
|
||||
// Package databricks is the Databricks provider. Sources are workspace
|
||||
// profiles from ~/.databrickscfg, containers are secret scopes, and objects
|
||||
// are secrets. Secrets in Azure Key Vault-backed scopes are read through
|
||||
// Databricks, falling back to the vault itself.
|
||||
package databricks
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/databricks/databricks-sdk-go/apierr"
|
||||
"github.com/databricks/databricks-sdk-go/client"
|
||||
dbconfig "github.com/databricks/databricks-sdk-go/config"
|
||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
|
||||
)
|
||||
|
||||
// KeyVault reads Azure Key Vault secrets directly; the Azure provider
|
||||
// implements it. Containers passed to it only have Detail (the vault URL)
|
||||
// set.
|
||||
type KeyVault interface {
|
||||
Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error)
|
||||
Value(ctx context.Context, o provider.Object) (provider.SecretValue, error)
|
||||
}
|
||||
|
||||
type profile struct {
|
||||
name string
|
||||
host string
|
||||
authType string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
profiles []profile
|
||||
keyVault KeyVault // nil disables the fallback
|
||||
newClient func(profile) (*workspace.SecretsAPI, error)
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[string]*workspace.SecretsAPI // by profile name
|
||||
vaults map[string]string // AKV-backed scope ID → vault URL
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
// New reads workspace profiles from the Databricks config file
|
||||
// ($DATABRICKS_CONFIG_FILE or ~/.databrickscfg), leaving out hideProfiles.
|
||||
// A missing file yields a provider with no sources.
|
||||
func New(kv KeyVault, hideProfiles []string) (*Provider, error) {
|
||||
path := os.Getenv("DATABRICKS_CONFIG_FILE")
|
||||
if path == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path = filepath.Join(home, ".databrickscfg")
|
||||
}
|
||||
all, err := readProfiles(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var profiles []profile
|
||||
for _, pr := range all {
|
||||
if !config.Hidden(hideProfiles, pr.name, pr.name) {
|
||||
profiles = append(profiles, pr)
|
||||
}
|
||||
}
|
||||
return &Provider{
|
||||
profiles: profiles,
|
||||
keyVault: kv,
|
||||
newClient: profileClient,
|
||||
clients: map[string]*workspace.SecretsAPI{},
|
||||
vaults: map[string]string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// profileClient is a client for the secrets API alone. The SDK's
|
||||
// WorkspaceClient would link in every Databricks service, some 10 MB.
|
||||
func profileClient(p profile) (*workspace.SecretsAPI, error) {
|
||||
return secretsClient(&dbconfig.Config{Profile: p.name})
|
||||
}
|
||||
|
||||
func secretsClient(cfg *dbconfig.Config) (*workspace.SecretsAPI, error) {
|
||||
c, err := client.New(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workspace.NewSecrets(c), nil
|
||||
}
|
||||
|
||||
// readProfiles returns the workspace profiles in file order, skipping
|
||||
// account-console profiles, which have no secret scopes.
|
||||
func readProfiles(path string) ([]profile, error) {
|
||||
f, err := ini.Load(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
var out []profile
|
||||
for _, s := range f.Sections() {
|
||||
host := s.Key("host").String()
|
||||
if host == "" || s.HasKey("account_id") || strings.Contains(host, "accounts.") {
|
||||
continue
|
||||
}
|
||||
out = append(out, profile{name: s.Name(), host: host, authType: s.Key("auth_type").String()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loginCommand is what signs the user back in for a profile, or nil if
|
||||
// there is nothing to run (e.g. a personal access token).
|
||||
func loginCommand(p profile) []string {
|
||||
switch p.authType {
|
||||
case "azure-cli":
|
||||
return []string{"az", "login"}
|
||||
case "pat":
|
||||
return nil
|
||||
default:
|
||||
return []string{"databricks", "auth", "login", "--profile", p.name}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return provider.KindDatabricks }
|
||||
func (p *Provider) ID() string { return string(provider.KindDatabricks) }
|
||||
func (p *Provider) Label() string { return string(provider.KindDatabricks) }
|
||||
|
||||
func (p *Provider) Sources(context.Context) ([]provider.Source, error) {
|
||||
out := make([]provider.Source, 0, len(p.profiles))
|
||||
for _, pr := range p.profiles {
|
||||
host := strings.TrimPrefix(pr.host, "https://")
|
||||
out = append(out, provider.Source{
|
||||
Kind: provider.KindDatabricks, ID: pr.name, Name: pr.name,
|
||||
Detail: host, URL: "https://" + host,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) profile(name string) (profile, bool) {
|
||||
for _, pr := range p.profiles {
|
||||
if pr.name == name {
|
||||
return pr, true
|
||||
}
|
||||
}
|
||||
return profile{}, false
|
||||
}
|
||||
|
||||
func (p *Provider) client(name string) (*workspace.SecretsAPI, profile, error) {
|
||||
pr, ok := p.profile(name)
|
||||
if !ok {
|
||||
return nil, pr, fmt.Errorf("unknown profile %q", name)
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if c, ok := p.clients[name]; ok {
|
||||
return c, pr, nil
|
||||
}
|
||||
c, err := p.newClient(pr)
|
||||
if err != nil {
|
||||
return nil, pr, classify(pr, err)
|
||||
}
|
||||
p.clients[name] = c
|
||||
return c, pr, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
w, pr, err := p.client(src.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes, err := w.ListScopesAll(ctx)
|
||||
if err != nil {
|
||||
return nil, classify(pr, err)
|
||||
}
|
||||
out := make([]provider.Container, 0, len(scopes))
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, s := range scopes {
|
||||
c := provider.Container{Source: src, ID: src.ID + "/" + s.Name, Name: s.Name, Detail: "Databricks-backed"}
|
||||
if s.BackendType == workspace.ScopeBackendTypeAzureKeyvault && s.KeyvaultMetadata != nil {
|
||||
c.Detail = akvDetail + s.KeyvaultMetadata.DnsName
|
||||
c.URL = azure.PortalResourceURL("", s.KeyvaultMetadata.ResourceId, "overview")
|
||||
p.vaults[c.ID] = s.KeyvaultMetadata.DnsName
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Container) int {
|
||||
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
|
||||
// back to the scope's Detail, so a scope remembered from an earlier session
|
||||
// (a favourite) works before its workspace's scopes are listed.
|
||||
func (p *Provider) vaultURL(c provider.Container) string {
|
||||
p.mu.Lock()
|
||||
url := p.vaults[c.ID]
|
||||
p.mu.Unlock()
|
||||
if url == "" {
|
||||
url, _ = strings.CutPrefix(c.Detail, akvDetail)
|
||||
if url == c.Detail {
|
||||
url = ""
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const akvDetail = "Azure Key Vault-backed: "
|
||||
|
||||
func secretObject(c provider.Container, key string, updated time.Time) provider.Object {
|
||||
return provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeSecret,
|
||||
ID: c.ID + "/" + key,
|
||||
Name: key,
|
||||
Updated: updated,
|
||||
Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
|
||||
URL: azure.ObjectListURL(c, provider.TypeSecret), // AKV-backed only
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
if t != provider.TypeSecret {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
w, pr, err := p.client(c.Source.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Object
|
||||
secrets, err := w.ListSecretsAll(ctx, workspace.ListSecretsRequest{Scope: c.Name})
|
||||
if err == nil {
|
||||
for _, s := range secrets {
|
||||
var updated time.Time
|
||||
if s.LastUpdatedTimestamp > 0 {
|
||||
updated = time.UnixMilli(s.LastUpdatedTimestamp)
|
||||
}
|
||||
out = append(out, secretObject(c, s.Key, updated))
|
||||
}
|
||||
} else {
|
||||
dbxErr := classify(pr, err)
|
||||
out, err = p.vaultObjects(ctx, c)
|
||||
if err != nil {
|
||||
return nil, combine(dbxErr, err)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
|
||||
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
|
||||
url := p.vaultURL(c)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return nil, errNoFallback
|
||||
}
|
||||
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]provider.Object, 0, len(secrets))
|
||||
for _, s := range secrets {
|
||||
out = append(out, secretObject(c, s.Name, s.Updated))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
return o, ctx.Err()
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
w, pr, err := p.client(o.Container.Source.ID)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
resp, err := w.GetSecret(ctx, workspace.GetSecretRequest{Scope: o.Container.Name, Key: o.Name})
|
||||
if err == nil {
|
||||
b, derr := base64.StdEncoding.DecodeString(resp.Value)
|
||||
if derr != nil {
|
||||
return provider.SecretValue{}, fmt.Errorf("decoding secret: %w", derr)
|
||||
}
|
||||
return provider.NewSecretValue(b), nil
|
||||
}
|
||||
dbxErr := classify(pr, err)
|
||||
|
||||
url := p.vaultURL(o.Container)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return provider.SecretValue{}, dbxErr
|
||||
}
|
||||
v, kvErr := p.keyVault.Value(ctx, provider.Object{
|
||||
Container: provider.Container{Detail: url},
|
||||
Type: provider.TypeSecret,
|
||||
Name: o.Name,
|
||||
})
|
||||
if kvErr != nil {
|
||||
return provider.SecretValue{}, combine(dbxErr, kvErr)
|
||||
}
|
||||
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, 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
|
||||
}
|
||||
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
|
||||
// from the API, or failing to get a token before the request was sent.
|
||||
func classify(pr profile, err error) error {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
} else if !strings.Contains(err.Error(), "cannot get access token") &&
|
||||
!strings.Contains(err.Error(), " auth: ") {
|
||||
return err
|
||||
}
|
||||
return &provider.AuthError{Kind: provider.KindDatabricks, Login: loginCommand(pr), Err: err}
|
||||
}
|
||||
|
||||
var _ provider.Locator = (*Provider)(nil)
|
||||
|
||||
// Locate finds the profile for a workspace host. It doesn't contact the
|
||||
// workspace.
|
||||
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
|
||||
want := normalizeHost(host)
|
||||
for _, pr := range p.profiles {
|
||||
if normalizeHost(pr.host) == want {
|
||||
return pr.name, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func normalizeHost(h string) string {
|
||||
h = strings.ToLower(strings.TrimSpace(h))
|
||||
h = strings.TrimPrefix(h, "https://")
|
||||
return strings.TrimSuffix(h, "/")
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package databricks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dbconfig "github.com/databricks/databricks-sdk-go/config"
|
||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func TestReadProfiles(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "databrickscfg")
|
||||
cfg := `[DEFAULT]
|
||||
host = https://adb-1.1.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[INFRA-DEV]
|
||||
host = adb-2.2.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[ACCOUNT]
|
||||
host = https://accounts.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[acct-by-id]
|
||||
host = https://example.cloud.databricks.com
|
||||
account_id = 123
|
||||
|
||||
[oauth]
|
||||
host = https://adb-3.3.azuredatabricks.net
|
||||
auth_type = databricks-cli
|
||||
|
||||
[no-host]
|
||||
token = x
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readProfiles(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []profile{
|
||||
{name: "DEFAULT", host: "https://adb-1.1.azuredatabricks.net", authType: "azure-cli"},
|
||||
{name: "INFRA-DEV", host: "adb-2.2.azuredatabricks.net", authType: "azure-cli"},
|
||||
{name: "oauth", host: "https://adb-3.3.azuredatabricks.net", authType: "databricks-cli"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("profiles = %+v\nwant %+v", got, want)
|
||||
}
|
||||
|
||||
if got, err := readProfiles(filepath.Join(t.TempDir(), "missing")); err != nil || got != nil {
|
||||
t.Errorf("missing file: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCommand(t *testing.T) {
|
||||
for authType, want := range map[string][]string{
|
||||
"azure-cli": {"az", "login"},
|
||||
"databricks-cli": {"databricks", "auth", "login", "--profile", "p"},
|
||||
"": {"databricks", "auth", "login", "--profile", "p"},
|
||||
"pat": nil,
|
||||
} {
|
||||
if got := loginCommand(profile{name: "p", authType: authType}); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%q: %v, want %v", authType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeKeyVault stands in for the Azure provider.
|
||||
type fakeKeyVault struct {
|
||||
secrets map[string]string // name → value, for vault URL kvURL
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
const kvURL = "https://kv-app-prod.vault.azure.net/"
|
||||
|
||||
func (k *fakeKeyVault) Objects(_ context.Context, c provider.Container, _ provider.ObjectType) ([]provider.Object, error) {
|
||||
k.calls++
|
||||
if k.err != nil {
|
||||
return nil, k.err
|
||||
}
|
||||
if c.Detail != kvURL {
|
||||
return nil, errors.New("wrong vault " + c.Detail)
|
||||
}
|
||||
var out []provider.Object
|
||||
for name := range k.secrets {
|
||||
out = append(out, provider.Object{Name: name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *fakeKeyVault) Value(_ context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
k.calls++
|
||||
if k.err != nil {
|
||||
return provider.SecretValue{}, k.err
|
||||
}
|
||||
if o.Container.Detail != kvURL {
|
||||
return provider.SecretValue{}, errors.New("wrong vault " + o.Container.Detail)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(k.secrets[o.Name])), nil
|
||||
}
|
||||
|
||||
// workspaceAPI serves the secrets API. The "akv" scope behaves like an Azure
|
||||
// Key Vault-backed scope whose secrets Databricks refuses to read.
|
||||
func workspaceAPI(t *testing.T, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
write := func(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if status != 0 {
|
||||
write(w, status, map[string]any{"error_code": "UNAUTHENTICATED", "message": "Token is expired"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
switch {
|
||||
case r.URL.Path == "/api/2.0/secrets/scopes/list":
|
||||
write(w, 200, map[string]any{"scopes": []any{
|
||||
map[string]any{"name": "app", "backend_type": "DATABRICKS"},
|
||||
map[string]any{"name": "akv", "backend_type": "AZURE_KEYVAULT",
|
||||
"keyvault_metadata": map[string]any{"dns_name": kvURL, "resource_id": "/x/kv-app-prod"}},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"secrets": []any{
|
||||
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||
map[string]any{"key": "client-secret"},
|
||||
}})
|
||||
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/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")))})
|
||||
case strings.HasPrefix(r.URL.Path, "/api/2.0/secrets/") && q.Get("scope") == "akv":
|
||||
write(w, 400, map[string]any{"error_code": "BAD_REQUEST",
|
||||
"message": "Reading secrets from Azure KeyVault-backed scopes is not supported"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, srv *httptest.Server, kv KeyVault) *Provider {
|
||||
t.Helper()
|
||||
return &Provider{
|
||||
profiles: []profile{{name: "prod", host: srv.URL, authType: "azure-cli"}},
|
||||
keyVault: kv,
|
||||
newClient: func(profile) (*workspace.SecretsAPI, error) {
|
||||
return secretsClient(&dbconfig.Config{Host: srv.URL, Token: "token"})
|
||||
},
|
||||
clients: map[string]*workspace.SecretsAPI{},
|
||||
vaults: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func scopes(t *testing.T, p *Provider) map[string]provider.Container {
|
||||
t.Helper()
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
cs, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]provider.Container{}
|
||||
for _, c := range cs {
|
||||
out[c.Name] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestScopesSecretsAndValues(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||
sc := scopes(t, p)
|
||||
if sc["app"].Detail != "Databricks-backed" || sc["akv"].Detail != "Azure Key Vault-backed: "+kvURL {
|
||||
t.Errorf("scope details = %q / %q", sc["app"].Detail, sc["akv"].Detail)
|
||||
}
|
||||
|
||||
objs, err := p.Objects(context.Background(), sc["app"], provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objs) != 2 || objs[0].Name != "client-secret" || objs[1].Name != "storage-key" {
|
||||
t.Fatalf("objects = %+v", objs)
|
||||
}
|
||||
if objs[1].Updated.IsZero() || objs[1].Reference != "{{secrets/app/storage-key}}" {
|
||||
t.Errorf("metadata = %+v", objs[1])
|
||||
}
|
||||
|
||||
v, err := p.Value(context.Background(), objs[1])
|
||||
if err != nil || v.Reveal() != "value-of-storage-key" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVScopeFallsBackToKeyVault(t *testing.T) {
|
||||
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
akv := scopes(t, p)["akv"]
|
||||
|
||||
objs, err := p.Objects(context.Background(), akv, provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objs) != 1 || objs[0].Name != "db-password" || objs[0].Reference != "{{secrets/akv/db-password}}" {
|
||||
t.Fatalf("objects = %+v", objs)
|
||||
}
|
||||
v, err := p.Value(context.Background(), objs[0])
|
||||
if err != nil || v.Reveal() != "from-the-vault" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVFallbackForRememberedScope(t *testing.T) {
|
||||
// A favourite from an earlier session: scopes not listed yet.
|
||||
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
akv := provider.Container{
|
||||
Source: provider.Source{Kind: provider.KindDatabricks, ID: "prod"},
|
||||
ID: "prod/akv", Name: "akv", Detail: "Azure Key Vault-backed: " + kvURL,
|
||||
}
|
||||
v, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
|
||||
if err != nil || v.Reveal() != "from-the-vault" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVFallbackFailureReportsBoth(t *testing.T) {
|
||||
kvAuth := &provider.AuthError{Kind: provider.KindAzure, Login: []string{"az", "login"}, Err: errors.New("expired")}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), &fakeKeyVault{err: kvAuth})
|
||||
akv := scopes(t, p)["akv"]
|
||||
|
||||
_, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), "Key Vault fallback") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Error("Key Vault auth error not reachable for the sign-in prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFallbackForDatabricksBackedScopes(t *testing.T) {
|
||||
kv := &fakeKeyVault{}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
app := scopes(t, p)["app"]
|
||||
if _, err := p.Value(context.Background(), provider.Object{Container: app, Name: "missing"}); err != nil {
|
||||
// app/get returns a value for any key; just make sure the vault wasn't asked
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kv.calls != 0 {
|
||||
t.Errorf("Key Vault consulted for a Databricks-backed scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedIsAuthError(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 401), nil)
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
_, err := p.Containers(context.Background(), srcs[0])
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Fatalf("err = %v, want AuthError", err)
|
||||
}
|
||||
if auth.Kind != provider.KindDatabricks || !reflect.DeepEqual(auth.Login, []string{"az", "login"}) {
|
||||
t.Errorf("auth = %+v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
pr := profile{name: "p", authType: "databricks-cli"}
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(classify(pr, errors.New("default auth: azure-cli: cannot get access token: run az login")), &auth) {
|
||||
t.Error("token failure not classified as auth")
|
||||
}
|
||||
if errors.As(classify(pr, errors.New("dial tcp: no such host")), &auth) {
|
||||
t.Error("network error classified as auth")
|
||||
}
|
||||
if errors.As(classify(pr, context.Canceled), &auth) {
|
||||
t.Error("cancellation classified as auth")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user