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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user