feat: l for details; details for vaults, scopes and files; getting started; the age of cached lists #17

Merged
keb merged 17 commits from feat/l-for-details into main 2026-10-11 14:24:09 +02:00
7 changed files with 120 additions and 17 deletions
Showing only changes of commit 32a2c3be20 - Show all commits

No files matched your search

+4
View File
@@ -13,6 +13,10 @@ Secrets in Azure Key Vault-backed scopes are read through Databricks; if
Databricks refuses, lazysecrets reads the backing vault directly with your Databricks refuses, lazysecrets reads the backing vault directly with your
az login (which then needs the [Key Vault roles](azure.md#permissions)). az login (which then needs the [Key Vault roles](azure.md#permissions)).
When Databricks refuses, the pane says what to check: access to the
workspace and READ on the scope, or, when the workspace's IP access list
refused, the network you're on.
On a scope, the right pane shows who has access to it and how much On a scope, the right pane shows who has access to it and how much
(manage, write, read). Databricks shows that only to the scope's (manage, write, read). Databricks shows that only to the scope's
managers; to others it says so. managers; to others it says so.
+3 -3
View File
@@ -175,11 +175,11 @@ func TestForbiddenIsShortened(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\nCheck: have you activated your role or group in PIM?"; !strings.HasPrefix(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) t.Errorf("error = %q, want %q…", got, want)
} }
if hint := deniedHint("Client address is not authorized and caller is not a trusted service."); !strings.Contains(hint, "network") { if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") {
t.Errorf("firewall hint = %q", hint) t.Errorf("firewall checks = %q", checks)
} }
var re *azcore.ResponseError var re *azcore.ResponseError
if !errors.As(err, &re) || re.StatusCode != 403 { if !errors.As(err, &re) || re.StatusCode != 403 {
+16 -10
View File
@@ -280,7 +280,11 @@ 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`) var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`)
@@ -322,21 +326,23 @@ func (e *serviceError) Error() string {
if e.msg != "" { if e.msg != "" {
s += ": " + e.msg s += ": " + e.msg
} }
if e.status == http.StatusForbidden {
s += "\n" + deniedHint(e.msg)
}
return s return s
} }
// deniedHint is what to check when Key Vault refuses: the network, for // deniedChecks are what to check when Key Vault refuses: the network,
// its firewall's refusals, else access. On a line of its own, after the // for its firewall's refusals, else access.
// message. func deniedChecks(msg string) []string {
func deniedHint(msg string) string {
if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") || if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") ||
strings.Contains(msg, "private link") { strings.Contains(msg, "private link") {
return "Check: are you on a network the vault lets in (VPN, office)? Its firewall limits where it can be reached from." 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",
} }
return "Check: have you activated your role or group in PIM? Has the vault's owner given you access (a Key Vault role, or its access policies)?"
} }
func (e *serviceError) Unwrap() error { return e.err } func (e *serviceError) Unwrap() error { return e.err }
+38 -3
View File
@@ -317,15 +317,47 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return v, nil return v, nil
} }
// deniedChecks are what to check when Databricks refuses: the network,
// when the workspace's IP access list refused, else access.
func deniedChecks(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",
}
}
return []string{
"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") var errNoFallback = errors.New("no Key Vault fallback")
// combine reports both the Databricks and the Key Vault failure. An auth // combine reports both the Databricks and the Key Vault failure, with
// error from either stays reachable with errors.As. // both their checks after. An auth error from either stays reachable
// with errors.As.
func combine(dbxErr, kvErr error) error { func combine(dbxErr, kvErr error) error {
if errors.Is(kvErr, errNoFallback) { if errors.Is(kvErr, errNoFallback) {
return dbxErr 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 // classify turns authentication failures into *provider.AuthError: a 401
@@ -336,6 +368,9 @@ func classify(pr profile, err error) error {
} }
var api *apierr.APIError var api *apierr.APIError
if errors.As(err, &api) { if errors.As(err, &api) {
if api.StatusCode == 403 {
return &provider.Denied{Err: err, Checks: deniedChecks(api.Message)}
}
if api.StatusCode != 401 { if api.StatusCode != 401 {
return err return err
} }
@@ -145,6 +145,8 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server {
map[string]any{"principal": "users", "permission": "READ"}, map[string]any{"principal": "users", "permission": "READ"},
map[string]any{"principal": "admins", "permission": "MANAGE"}, 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": 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"}) 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": case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
@@ -315,3 +317,22 @@ func TestScopeDetails(t *testing.T) {
t.Errorf("locked: %+v, %v", attrs, err) 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
if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• you have access to the workspace") {
t.Errorf("err %q", err)
}
if got := deniedChecks("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)
}
}
+18
View File
@@ -12,6 +12,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
) )
@@ -202,6 +203,23 @@ type Account struct {
Copy, CopyLabel string Copy, CopyLabel string
} }
// 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 // ContainerDetails is implemented by providers that can say more about a
// container than its listing does: a vault's settings, who has access to // 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 // a scope, how a file is encrypted and what git knows of it. It may go
+20 -1
View File
@@ -334,7 +334,7 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
case l.Loading && len(l.items) == 0: case l.Loading && len(l.items) == 0:
body = append(body, " "+spin+" loading…") body = append(body, " "+spin+" loading…")
case l.Err != nil: case l.Err != nil:
for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") { for _, line := range wrapLines(l.Err.Error(), inner-1) {
body = append(body, st.Error.Render(" "+line)) body = append(body, st.Error.Render(" "+line))
} }
case len(l.visible) == 0 && len(l.items) > 0: case len(l.visible) == 0 && len(l.items) > 0:
@@ -393,3 +393,22 @@ func (l *List[T]) scroll() {
} }
l.offset = max(0, min(l.offset, len(l.visible)-rows)) l.offset = max(0, min(l.offset, len(l.visible)-rows))
} }
// wrapLines wraps text to width, line by line; a "• " item's further
// lines line up under its text.
func wrapLines(text string, width int) []string {
var out []string
for _, line := range strings.Split(text, "\n") {
indent := ""
if strings.HasPrefix(line, "• ") {
indent = " "
}
for i, l := range strings.Split(ansi.Wrap(line, max(1, width-len(indent)), " -/"), "\n") {
if i > 0 {
l = indent + l
}
out = append(out, l)
}
}
return out
}