From bc11b8e62afb697b66f15237f0eec0212898c6a3 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 23 Oct 2026 13:35:18 +0530 Subject: [PATCH] feat(plugins): add per-tenant consent and approvals - Record approvals per tenant and plugin, keyed on the permissions hash - Pin approvals for CI with AUTH0_CLI_PLUGIN_APPROVALS - Refuse to approve unless a person is at an interactive terminal - Show the full permissions, or only the changes for an approved tenant - Remove a plugin's approvals on uninstall and a tenant's on logout --- internal/cli/logout.go | 9 + internal/cli/logout_test.go | 104 +++ internal/config/config.go | 11 + internal/config/config_test.go | 18 + internal/plugins/approvals/approvals.go | 285 ++++++++ internal/plugins/approvals/approvals_test.go | 479 +++++++++++++ internal/plugins/approvals/consent.go | 259 +++++++ internal/plugins/approvals/consent_test.go | 373 +++++++++++ internal/plugins/approvals/errors.go | 64 ++ internal/plugins/approvals/errors_test.go | 34 + internal/plugins/approvals/file.go | 298 +++++++++ internal/plugins/approvals/file_test.go | 152 +++++ internal/plugins/approvals/internal_test.go | 83 +++ internal/plugins/approvals/pins.go | 52 ++ internal/plugins/approvals/pins_test.go | 111 +++ internal/plugins/approvals/render.go | 432 ++++++++++++ internal/plugins/approvals/render_test.go | 668 +++++++++++++++++++ 17 files changed, 3432 insertions(+) create mode 100644 internal/cli/logout_test.go create mode 100644 internal/plugins/approvals/approvals.go create mode 100644 internal/plugins/approvals/approvals_test.go create mode 100644 internal/plugins/approvals/consent.go create mode 100644 internal/plugins/approvals/consent_test.go create mode 100644 internal/plugins/approvals/errors.go create mode 100644 internal/plugins/approvals/errors_test.go create mode 100644 internal/plugins/approvals/file.go create mode 100644 internal/plugins/approvals/file_test.go create mode 100644 internal/plugins/approvals/internal_test.go create mode 100644 internal/plugins/approvals/pins.go create mode 100644 internal/plugins/approvals/pins_test.go create mode 100644 internal/plugins/approvals/render.go create mode 100644 internal/plugins/approvals/render_test.go diff --git a/internal/cli/logout.go b/internal/cli/logout.go index 0e9b82657..2a2ebe301 100644 --- a/internal/cli/logout.go +++ b/internal/cli/logout.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/auth0/auth0-cli/internal/keyring" + "github.com/auth0/auth0-cli/internal/plugins/approvals" ) func logoutCmd(cli *cli) *cobra.Command { @@ -23,6 +24,14 @@ func logoutCmd(cli *cli) *cobra.Command { return err } + // The approvals go first, so that a failure leaves the tenant logged in and + // logging out can be tried again. Consent never outlives the session it + // was given in. + pluginApprovals := &approvals.Approvals{Dir: cli.Config.PluginsDir()} + if err := pluginApprovals.RemoveTenant(selectedTenant); err != nil { + return fmt.Errorf("failed to remove the plugin approvals of the tenant %q: %w", selectedTenant, err) + } + if err := cli.Config.RemoveTenant(selectedTenant); err != nil { return fmt.Errorf("failed to log out from the tenant %q: %w", selectedTenant, err) } diff --git a/internal/cli/logout_test.go b/internal/cli/logout_test.go new file mode 100644 index 000000000..5a278a1e3 --- /dev/null +++ b/internal/cli/logout_test.go @@ -0,0 +1,104 @@ +package cli + +import ( + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gokeyring "github.com/zalando/go-keyring" + + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/plugins/approvals" +) + +func TestLogoutRemovesPluginApprovals(t *testing.T) { + const ( + tenant = "acme.us.auth0.com" + other = "other.us.auth0.com" + id = "auth0/checkmate" + hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + + // Setup logs in to two tenants in a new home directory, and returns the CLI + // and the approvals in the plugins directory next to its config file. The path + // is written out here so that the test notices if logout looks somewhere else. + setup := func(t *testing.T) (*cli, *approvals.Approvals) { + t.Helper() + + gokeyring.MockInit() + + home := t.TempDir() + t.Setenv("HOME", home) + + // The config remembers that its file was missing, so it is saved through + // one config and read by the CLI through another. + var saved config.Config + for _, domain := range []string{tenant, other} { + require.NoError(t, saved.AddTenant(config.Tenant{Domain: domain})) + } + + return &cli{renderer: display.NewRenderer()}, &approvals.Approvals{Dir: filepath.Join(home, ".config", "auth0", "plugins")} + } + + logout := func(c *cli) error { + cmd := logoutCmd(c) + cmd.SetArgs([]string{tenant}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + return cmd.Execute() + } + + t.Run("approvals of the tenant go and the others stay", func(t *testing.T) { + c, plugins := setup(t) + require.NoError(t, plugins.Approve(t.Context(), tenant, id, hash)) + require.NoError(t, plugins.Approve(t.Context(), other, id, hash)) + + require.NoError(t, logout(c)) + + assert.Error(t, plugins.Check(tenant, id, hash)) + assert.NoError(t, plugins.Check(other, id, hash)) + + _, err := c.Config.GetTenant(tenant) + assert.Error(t, err, "the tenant is logged out") + }) + + t.Run("a failing removal stops the logout and the tenant stays configured", func(t *testing.T) { + c, plugins := setup(t) + + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("a directory that cannot be written needs a user who is not root, on a system with permission bits") + } + + // A damaged file is deleted, which fails when its directory cannot be + // written. The lock file exists already, so that taking the lock still works. + require.NoError(t, os.MkdirAll(plugins.Dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(plugins.Dir, "approvals.json"), []byte("not json"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(plugins.Dir, ".approvals.lock"), nil, 0o600)) + require.NoError(t, os.Chmod(plugins.Dir, 0o500)) + + t.Cleanup(func() { _ = os.Chmod(plugins.Dir, 0o700) }) + + err := logout(c) + + require.ErrorContains(t, err, "failed to remove the plugin approvals") + assert.ErrorContains(t, err, "failed to remove the damaged plugin approvals file") + + _, err = c.Config.GetTenant(tenant) + assert.NoError(t, err, "the tenant is still logged in, so logout can be tried again") + }) + + t.Run("a user who never used plugins sees no change", func(t *testing.T) { + c, plugins := setup(t) + + require.NoError(t, logout(c)) + + _, err := os.Stat(plugins.Dir) + assert.ErrorIs(t, err, os.ErrNotExist) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index f209a11ee..14dfbeaa1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -225,6 +225,17 @@ func (c *Config) ensureInstallIDAssigned() { c.InstallID = uuid.NewString() } +// PluginsDir returns the directory of the plugin store, which sits next to the +// config file. It does not create the directory or change the config. +func (c *Config) PluginsDir() string { + configPath := c.path + if configPath == "" { + configPath = defaultPath() + } + + return filepath.Join(filepath.Dir(configPath), "plugins") +} + func (c *Config) loadFromDisk() error { if c.path == "" { c.path = defaultPath() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 226f68f8f..9072d935a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path" + "path/filepath" "testing" "time" @@ -23,6 +24,23 @@ func TestDefaultPath(t *testing.T) { assert.Equal(t, expectedPath, actualPath) } +func TestConfig_PluginsDir(t *testing.T) { + t.Run("it sits next to the config file", func(t *testing.T) { + dir := t.TempDir() + config := &Config{path: path.Join(dir, "auth0", "config.json")} + + assert.Equal(t, filepath.Join(dir, "auth0", "plugins"), config.PluginsDir()) + assert.Equal(t, path.Join(dir, "auth0", "config.json"), config.path, "the path is left alone") + }) + + t.Run("it sits next to the default config file", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + assert.Equal(t, filepath.Join(home, ".config", "auth0", "plugins"), (&Config{}).PluginsDir()) + }) +} + func TestConfig_LoadFromDisk(t *testing.T) { t.Run("it fails to load a non existent config file", func(t *testing.T) { config := &Config{path: "i-am-a-non-existent-config.json"} diff --git a/internal/plugins/approvals/approvals.go b/internal/plugins/approvals/approvals.go new file mode 100644 index 000000000..65b8114e1 --- /dev/null +++ b/internal/plugins/approvals/approvals.go @@ -0,0 +1,285 @@ +// Package approvals records which permissions of which plugin a person approved +// on which tenant, and answers whether a plugin is approved to run. +// +// An approval is keyed on the consent hash of the plugin, so a release that asks +// for the same permissions needs no new approval and one that asks for anything +// else does. Everything fails closed: a file that cannot be trusted is an error, +// never an empty list of approvals, and only a person at an interactive +// terminal can create an approval. +package approvals + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "slices" + "strings" + "time" +) + +// Approvals is the set of approvals in one plugins directory. +type Approvals struct { + // Dir is the plugins directory, which holds approvals.json. + Dir string + + // Pins approve plugins on every tenant, for CI. It is what ParsePins returns + // for the value of PinsEnv. A nil value means there are no pins. + Pins Pins + + // Now returns the current time. When it is nil, time.Now is used. + Now func() time.Time +} + +// Record is one approval, as List returns it. +type Record struct { + Tenant string + PermissionsHash string + ApprovedAt time.Time +} + +// foldTenant returns the tenant in the form it is stored in, which is lower case, +// or an error when it is not a tenant domain. +func foldTenant(tenant string) (string, error) { + if !tenantPattern.MatchString(tenant) { + return "", fmt.Errorf("%q is not a tenant domain", tenant) + } + + return strings.ToLower(tenant), nil +} + +// checkID returns an error when the plugin ID is not in the form registry/name. +func checkID(pluginID string) error { + if !idPattern.MatchString(pluginID) { + return fmt.Errorf("%q is not a plugin ID in the form registry/name", pluginID) + } + + return nil +} + +// checkApproval validates the arguments of Approve, Approved and Check, and +// returns the tenant in the form it is stored in. +func checkApproval(tenant, pluginID, hash string) (string, error) { + folded, err := foldTenant(tenant) + if err != nil { + return "", err + } + + if err := checkID(pluginID); err != nil { + return "", err + } + + if !hashPattern.MatchString(hash) { + return "", fmt.Errorf("%q is not a consent hash, sha256: followed by a lowercase hex SHA-256", hash) + } + + return folded, nil +} + +// Approve records that the permissions with the given consent hash of the plugin +// are approved for the tenant. It replaces what was approved for that plugin on +// that tenant before, so an earlier permission set stops being approved. This is +// the only function that creates an approval, and it does not check who is +// asking: the Consenter does that. +func (a *Approvals) Approve(ctx context.Context, tenant, pluginID, hash string) error { + folded, err := checkApproval(tenant, pluginID, hash) + if err != nil { + return err + } + + release, err := a.lock(ctx) + if err != nil { + return err + } + defer release() + + current, err := a.load() + if err != nil { + return err + } + + if current[folded] == nil { + current[folded] = map[string]approval{} + } + + now := time.Now + if a.Now != nil { + now = a.Now + } + + current[folded][pluginID] = approval{PermissionsHash: hash, ApprovedAt: now().UTC()} + + return a.write(current) +} + +// Approved reports whether the permissions with the given consent hash of the +// plugin are approved for the tenant, either by a pin or by a recorded approval. +func (a *Approvals) Approved(tenant, pluginID, hash string) (bool, error) { + err := a.Check(tenant, pluginID, hash) + + var notApproved *NotApprovedError + if errors.As(err, ¬Approved) { + return false, nil + } + + return err == nil, err +} + +// Check returns nil when the plugin is approved for the tenant at the given +// consent hash. Otherwise it returns a *NotApprovedError, or an error that wraps +// ErrDamaged when the approvals file cannot be trusted. +func (a *Approvals) Check(tenant, pluginID, hash string) error { + folded, err := checkApproval(tenant, pluginID, hash) + if err != nil { + return err + } + + if a.Pins.approves(pluginID, hash) { + return nil + } + + current, err := a.load() + if err != nil { + return err + } + + recorded, found := current[folded][pluginID] + if found && recorded.PermissionsHash == hash { + return nil + } + + return &NotApprovedError{Tenant: folded, ID: pluginID, Changed: found} +} + +// Usable returns nil when the approvals of the tenant can be read, which is when +// the tenant is a tenant domain and the approvals file is missing or valid. It +// returns the error that Check, Approved and List return otherwise, which wraps +// ErrDamaged when the file cannot be trusted. It creates and changes nothing. +func (a *Approvals) Usable(tenant string) error { + if _, err := foldTenant(tenant); err != nil { + return err + } + + _, err := a.load() + + return err +} + +// List returns the approvals of a plugin on every tenant, sorted by tenant. +func (a *Approvals) List(pluginID string) ([]Record, error) { + if err := checkID(pluginID); err != nil { + return nil, err + } + + current, err := a.load() + if err != nil { + return nil, err + } + + var records []Record + + for tenant, plugs := range current { + if stored, found := plugs[pluginID]; found { + records = append(records, Record{ + Tenant: tenant, + PermissionsHash: stored.PermissionsHash, + ApprovedAt: stored.ApprovedAt, + }) + } + } + + slices.SortFunc(records, func(x, y Record) int { return strings.Compare(x.Tenant, y.Tenant) }) + + return records, nil +} + +// RemoveAll removes the approvals of a plugin on every tenant. It satisfies +// store.Approvals. Nothing is created when there is no approvals file. +func (a *Approvals) RemoveAll(pluginID string) error { + if !idPattern.MatchString(pluginID) { + return nil // Nothing can be stored under it. + } + + return a.remove(func(current table) bool { + var changed bool + + for tenant, plugs := range current { + if _, found := plugs[pluginID]; found { + delete(plugs, pluginID) + + changed = true + } + + if len(plugs) == 0 { + delete(current, tenant) + } + } + + return changed + }) +} + +// RemoveTenant removes the approvals of every plugin on the tenant. Nothing is +// created when there is no approvals file. +func (a *Approvals) RemoveTenant(tenant string) error { + folded, err := foldTenant(tenant) + if err != nil { + return nil // Nothing can be stored under it. + } + + return a.remove(func(current table) bool { + _, found := current[folded] + delete(current, folded) + + return found + }) +} + +// remove changes the approvals with drop, which reports whether it changed +// anything. It does nothing when there is no approvals file. A file that cannot be trusted is deleted instead: consent can only +// shrink here, and removing a plugin or logging out must not be blocked by a bad +// file. A directory that has content, in the place of the file, is not deleted +// recursively, so removing it fails with the error of the operating system, and +// the CLI never empties a directory it did not create. +func (a *Approvals) remove(drop func(current table) bool) error { + if err := a.checkDir(); err != nil { + return err + } + + // With no plugins directory, or a file in its place, there is nothing to + // remove, and the directory is not created for it. Approve still reports the + // file in the way. + if info, err := os.Stat(a.Dir); errors.Is(err, fs.ErrNotExist) || (err == nil && !info.IsDir()) { + return nil + } + + if _, err := os.Lstat(a.path()); errors.Is(err, fs.ErrNotExist) { + return nil + } + + release, err := a.lock(context.Background()) + if err != nil { + return err + } + defer release() + + current, err := a.load() + if errors.Is(err, ErrDamaged) { + if err := os.Remove(a.path()); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("failed to remove the damaged plugin approvals file: %w", err) + } + + return nil + } + + if err != nil { + return err + } + + if !drop(current) { + return nil + } + + return a.write(current) +} diff --git a/internal/plugins/approvals/approvals_test.go b/internal/plugins/approvals/approvals_test.go new file mode 100644 index 000000000..d576861a1 --- /dev/null +++ b/internal/plugins/approvals/approvals_test.go @@ -0,0 +1,479 @@ +package approvals_test + +import ( + "cmp" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins/approvals" + "github.com/auth0/auth0-cli/internal/plugins/filelock" +) + +const ( + tenant = "acme.us.auth0.com" + id = "auth0/checkmate" + hashA = "sha256:" + hexA + hashB = "sha256:" + hexB + hexA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + hexB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + upper = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +) + +var approvedAt = time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + +// removers are the functions that remove approvals, which drop the plugin and +// the tenant above. +var removers = map[string]func(*approvals.Approvals) error{ + "RemoveAll": func(a *approvals.Approvals) error { return a.RemoveAll(id) }, + "RemoveTenant": func(a *approvals.Approvals) error { return a.RemoveTenant(tenant) }, +} + +// newApprovals returns approvals in a plugins directory that does not exist yet. +func newApprovals(t *testing.T) *approvals.Approvals { + t.Helper() + + return &approvals.Approvals{ + Dir: filepath.Join(t.TempDir(), "plugins"), + Now: func() time.Time { return approvedAt }, + } +} + +// writeFile puts content in the approvals file with the permissions that the +// CLI gives it. +func writeFile(t *testing.T, a *approvals.Approvals, content string) { + t.Helper() + + require.NoError(t, os.MkdirAll(a.Dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(a.Dir, "approvals.json"), []byte(content), 0o600)) +} + +func TestApprove(t *testing.T) { + tests := []struct { + name string + tenant string + id string + hash string + want bool + }{ + {name: "same tenant, plugin and hash", tenant: tenant, id: id, hash: hashA, want: true}, + {name: "upper case tenant", tenant: "ACME.us.Auth0.com", id: id, hash: hashA, want: true}, + {name: "another tenant", tenant: "other.us.auth0.com", id: id, hash: hashA}, + {name: "another hash", tenant: tenant, id: id, hash: hashB}, + {name: "another plugin", tenant: tenant, id: "auth0/other", hash: hashA}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + a := newApprovals(t) + require.NoError(t, a.Approve(t.Context(), tenant, id, hashA)) + + approved, err := a.Approved(test.tenant, test.id, test.hash) + + require.NoError(t, err) + assert.Equal(t, test.want, approved) + }) + } + + t.Run("the tenant is folded when it is approved, too", func(t *testing.T) { + a := newApprovals(t) + require.NoError(t, a.Approve(t.Context(), "ACME.us.auth0.com", id, hashA)) + + approved, err := a.Approved(tenant, id, hashA) + + require.NoError(t, err) + assert.True(t, approved) + }) + + t.Run("a new hash replaces the old one", func(t *testing.T) { + a := newApprovals(t) + require.NoError(t, a.Approve(t.Context(), tenant, id, hashA)) + require.NoError(t, a.Approve(t.Context(), tenant, id, hashB)) + + oldApproved, err := a.Approved(tenant, id, hashA) + require.NoError(t, err) + + newApproved, err := a.Approved(tenant, id, hashB) + require.NoError(t, err) + + assert.False(t, oldApproved) + assert.True(t, newApproved) + + records, err := a.List(id) + require.NoError(t, err) + assert.Len(t, records, 1) + }) + + t.Run("the file is private and nothing else is left behind", func(t *testing.T) { + a := newApprovals(t) + require.NoError(t, a.Approve(t.Context(), tenant, id, hashA)) + + info, err := os.Stat(filepath.Join(a.Dir, "approvals.json")) + require.NoError(t, err) + + if info.Mode().Perm() != 0o600 && runtime.GOOS != "windows" { + t.Errorf("mode is %v, want 0600", info.Mode().Perm()) + } + + assert.ElementsMatch(t, []string{"approvals.json", ".approvals.lock"}, dirNames(t, a.Dir)) + }) +} + +func TestApproveRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + tenant string + id string + hash string + }{ + {name: "empty tenant", tenant: "", id: id, hash: hashA}, + {name: "tenant with a slash", tenant: "acme/evil", id: id, hash: hashA}, + {name: "tenant with a space", tenant: "acme .auth0.com", id: id, hash: hashA}, + {name: "tenant with a line break", tenant: "acme.auth0.com\n", id: id, hash: hashA}, + {name: "tenant with a character that folds to a letter", tenant: "ac\u212ae.auth0.com", id: id, hash: hashA}, + {name: "tenant that is too long", tenant: strings.Repeat("a", 254), id: id, hash: hashA}, + {name: "ID without a registry", tenant: tenant, id: "checkmate", hash: hashA}, + {name: "ID in upper case", tenant: tenant, id: "Auth0/checkmate", hash: hashA}, + {name: "ID with a path", tenant: tenant, id: "auth0/../checkmate", hash: hashA}, + {name: "hash without a prefix", tenant: tenant, id: id, hash: hexA}, + {name: "hash in upper case", tenant: tenant, id: id, hash: "sha256:" + upper}, + {name: "short hash", tenant: tenant, id: id, hash: "sha256:abc"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + a := newApprovals(t) + + require.Error(t, a.Approve(t.Context(), test.tenant, test.id, test.hash)) + require.Error(t, a.Check(test.tenant, test.id, test.hash)) + + _, err := os.Stat(a.Dir) + assert.ErrorIs(t, err, os.ErrNotExist) + }) + } +} + +func TestCheck(t *testing.T) { + a := newApprovals(t) + require.NoError(t, a.Approve(t.Context(), tenant, id, hashA)) + + tests := []struct { + name string + tenant string + hash string + wantErr bool + wantChanged bool + }{ + {name: "approved", tenant: tenant, hash: hashA}, + {name: "another hash was approved", tenant: tenant, hash: hashB, wantErr: true, wantChanged: true}, + {name: "another tenant", tenant: "other.us.auth0.com", hash: hashA, wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := a.Check(test.tenant, id, test.hash) + + if !test.wantErr { + require.NoError(t, err) + return + } + + var notApproved *approvals.NotApprovedError + + require.True(t, errors.As(err, ¬Approved)) + assert.Equal(t, approvals.NotApprovedError{Tenant: test.tenant, ID: id, Changed: test.wantChanged}, *notApproved) + }) + } +} + +func TestList(t *testing.T) { + a := newApprovals(t) + + for _, approval := range []struct{ tenant, id, hash string }{ + {"zeta.auth0.com", id, hashA}, + {"alpha.auth0.com", id, hashB}, + {"alpha.auth0.com", "auth0/other", hashA}, + } { + require.NoError(t, a.Approve(t.Context(), approval.tenant, approval.id, approval.hash)) + } + + records, err := a.List(id) + + require.NoError(t, err) + assert.Equal(t, []approvals.Record{ + {Tenant: "alpha.auth0.com", PermissionsHash: hashB, ApprovedAt: approvedAt}, + {Tenant: "zeta.auth0.com", PermissionsHash: hashA, ApprovedAt: approvedAt}, + }, records) + + records, err = newApprovals(t).List(id) + + require.NoError(t, err) + assert.Empty(t, records) +} + +func TestUsable(t *testing.T) { + tests := []struct { + name string + tenant string // Empty means the tenant of the other tests. + content string // Empty leaves the plugins directory out. + wantErr error + }{ + {name: "a new store"}, + {name: "a valid table", content: document(nil)}, + {name: "an upper case tenant", tenant: "ACME.us.auth0.com", content: document(nil)}, + {name: "a damaged table", content: "not json", wantErr: approvals.ErrDamaged}, + {name: "a table with an unknown field", content: document(map[string]string{"extra": `, "note": "x"`}), wantErr: approvals.ErrDamaged}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + a := newApprovals(t) + + if test.content != "" { + writeFile(t, a, test.content) + } + + err := a.Usable(cmp.Or(test.tenant, tenant)) + + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + } else { + require.NoError(t, err) + } + + if test.content == "" { + _, err := os.Stat(a.Dir) + assert.ErrorIs(t, err, os.ErrNotExist, "nothing is created") + } + }) + } + + t.Run("a tenant that is not a domain", func(t *testing.T) { + require.ErrorContains(t, newApprovals(t).Usable("acme/evil"), "is not a tenant domain") + }) +} + +// approveAll approves two plugins on two tenants. +func approveAll(t *testing.T, a *approvals.Approvals) { + t.Helper() + + for _, name := range []string{tenant, "other.us.auth0.com"} { + for _, pluginID := range []string{id, "auth0/other"} { + require.NoError(t, a.Approve(t.Context(), name, pluginID, hashA)) + } + } +} + +func TestRemoveAll(t *testing.T) { + a := newApprovals(t) + approveAll(t, a) + + require.NoError(t, a.RemoveAll(id)) + + for _, name := range []string{tenant, "other.us.auth0.com"} { + removed, err := a.Approved(name, id, hashA) + require.NoError(t, err) + assert.False(t, removed) + + kept, err := a.Approved(name, "auth0/other", hashA) + require.NoError(t, err) + assert.True(t, kept) + } +} + +func TestRemoveTenant(t *testing.T) { + a := newApprovals(t) + approveAll(t, a) + + require.NoError(t, a.RemoveTenant("ACME.us.auth0.com")) + + for _, pluginID := range []string{id, "auth0/other"} { + removed, err := a.Approved(tenant, pluginID, hashA) + require.NoError(t, err) + assert.False(t, removed) + + kept, err := a.Approved("other.us.auth0.com", pluginID, hashA) + require.NoError(t, err) + assert.True(t, kept) + } +} + +func TestRemoveWithoutFile(t *testing.T) { + for name, remove := range removers { + t.Run(name+" creates nothing when there is no plugins directory", func(t *testing.T) { + a := newApprovals(t) + + require.NoError(t, remove(a)) + + _, err := os.Stat(a.Dir) + assert.ErrorIs(t, err, os.ErrNotExist) + }) + + t.Run(name+" creates nothing when there is no file", func(t *testing.T) { + a := newApprovals(t) + require.NoError(t, os.MkdirAll(a.Dir, 0o700)) + + require.NoError(t, remove(a)) + + assert.Empty(t, dirNames(t, a.Dir)) + }) + } +} + +func TestRemoveWithAFileInPlaceOfTheDirectory(t *testing.T) { + for name, remove := range removers { + t.Run(name+" has nothing to remove and creates nothing", func(t *testing.T) { + a := newApprovals(t) + require.NoError(t, os.WriteFile(a.Dir, []byte("not a directory"), 0o600)) + + require.NoError(t, remove(a)) + + content, err := os.ReadFile(a.Dir) + require.NoError(t, err) + assert.Equal(t, "not a directory", string(content)) + assert.Equal(t, []string{"plugins"}, dirNames(t, filepath.Dir(a.Dir))) + }) + } + + t.Run("Approve reports it", func(t *testing.T) { + a := newApprovals(t) + require.NoError(t, os.WriteFile(a.Dir, []byte("not a directory"), 0o600)) + + require.ErrorContains(t, a.Approve(t.Context(), tenant, id, hashA), "failed to create the plugins directory") + }) +} + +func TestDirectoryMustBeAbsolute(t *testing.T) { + methods := map[string]func(*approvals.Approvals) error{ + "Approve": func(a *approvals.Approvals) error { return a.Approve(t.Context(), tenant, id, hashA) }, + "Approved": func(a *approvals.Approvals) error { return errOf(a.Approved(tenant, id, hashA)) }, + "Check": func(a *approvals.Approvals) error { return a.Check(tenant, id, hashA) }, + "List": func(a *approvals.Approvals) error { return errOf(a.List(id)) }, + "RemoveAll": removers["RemoveAll"], + "RemoveTenant": removers["RemoveTenant"], + } + + for _, dir := range []string{"", "plugins", filepath.Join("..", "plugins")} { + for name, call := range methods { + t.Run(fmt.Sprintf("%s with %q", name, dir), func(t *testing.T) { + // The working directory holds an approvals file that would approve, in + // the place that a relative path points to. + work := t.TempDir() + t.Chdir(work) + + planted := filepath.Join(work, "approvals.json") + if dir != "" { + planted = filepath.Join(work, dir, "approvals.json") + require.NoError(t, os.MkdirAll(filepath.Dir(planted), 0o700)) + } + + require.NoError(t, os.WriteFile(planted, []byte(document(nil)), 0o600)) + + before := dirNames(t, filepath.Dir(planted)) + + err := call(&approvals.Approvals{Dir: dir}) + + require.ErrorContains(t, err, "is not an absolute path") + assert.Equal(t, before, dirNames(t, filepath.Dir(planted)), "nothing is created") + + content, readErr := os.ReadFile(planted) + require.NoError(t, readErr) + assert.Equal(t, document(nil), string(content), "the file is neither read nor changed") + }) + } + } +} + +func TestConcurrentApprovals(t *testing.T) { + t.Run("no approval is lost", func(t *testing.T) { + a := newApprovals(t) + + var wg sync.WaitGroup + + for n := range 8 { + wg.Add(1) + + go func() { + defer wg.Done() + + assert.NoError(t, a.Approve(t.Context(), tenant, fmt.Sprintf("auth0/plugin-%d", n), hashA)) + }() + } + + wg.Wait() + + for n := range 8 { + approved, err := a.Approved(tenant, fmt.Sprintf("auth0/plugin-%d", n), hashA) + + require.NoError(t, err) + assert.True(t, approved, "plugin-%d", n) + } + }) + + t.Run("removing while the store holds its lock and another process approves does not deadlock", func(t *testing.T) { + a := newApprovals(t) + approveAll(t, a) + + // The store holds its own lock while it calls RemoveAll. + release, err := filelock.Acquire(t.Context(), filepath.Join(a.Dir, ".lock"), errors.New("busy")) + require.NoError(t, err) + + defer release() + + done := make(chan error, 2) + + go func() { done <- a.RemoveAll(id) }() + go func() { done <- a.Approve(t.Context(), tenant, "auth0/new", hashA) }() + + for range 2 { + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(10 * time.Second): + t.Fatal("RemoveAll or Approve is waiting for the lock of the store") + } + } + }) +} + +func TestApproveWaitsForTheLock(t *testing.T) { + a := newApprovals(t) + require.NoError(t, os.MkdirAll(a.Dir, 0o700)) + + release, err := filelock.Acquire(t.Context(), filepath.Join(a.Dir, ".approvals.lock"), errors.New("busy")) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + + require.ErrorIs(t, a.Approve(ctx, tenant, id, hashA), approvals.ErrBusy) + + release() + + require.NoError(t, a.Approve(t.Context(), tenant, id, hashA)) +} + +// dirNames lists the names in a directory. +func dirNames(t *testing.T, dir string) []string { + t.Helper() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + + return names +} diff --git a/internal/plugins/approvals/consent.go b/internal/plugins/approvals/consent.go new file mode 100644 index 000000000..0ccf9ca86 --- /dev/null +++ b/internal/plugins/approvals/consent.go @@ -0,0 +1,259 @@ +package approvals + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/store" +) + +// Session says who is behind the command. The zero value is not a person, so it +// cannot approve anything. +type Session struct { + // Interactive is true when the command runs at a terminal where a person can + // answer, and --no-input is not set. + Interactive bool + + // Agent is true in agent mode. An agent never approves a plugin, even at a + // terminal. + Agent bool +} + +// Prompter reads the answer of the person. +type Prompter interface { + // ReadLine shows the prompt and returns the line the person typed, without + // the line break. It returns an error, such as io.EOF, when there is no + // answer. + ReadLine(prompt string) (string, error) +} + +// Request is what a person is asked to approve. +type Request struct { + ID string + Version string + + // Old holds the permissions and commands that are installed now, and is nil + // for a first approval. The screen of an update only shows how New differs. + Old *plugins.Permissions + OldCommands []plugins.ConsentCommand + + New plugins.Permissions + NewCommands []plugins.ConsentCommand +} + +// Consenter asks a person to approve plugins on one tenant. The fields must all +// be set. It is not safe for concurrent use. Use one Consenter for each install +// or update operation, because what the person approved through ConsentFunc is +// only kept until the next Record, which writes it for the tenant and version +// that the person was asked about, and only for what was installed. +type Consenter struct { + Tenant string + Approvals *Approvals + Session Session + Prompter Prompter + + // Out receives the consent screen. + Out io.Writer + + // What the person approved through ConsentFunc that Record has not written + // yet, for each plugin ID. + granted map[string]grant +} + +// grant is what a person approved through ConsentFunc. +type grant struct { + // The tenant the person was asked about, folded to lower case. + tenant string + + // The version the person was shown, which Record compares with the version + // that was installed. + version string + + // The consent hash of what the person was shown. + hash string +} + +// Ask shows the request to the person and records the approval when they say +// yes. It returns ErrAgentMode or ErrNotInteractive, before it shows or reads +// anything, when the session is not a person, and ErrDeclined when the person +// does not approve. +func (c *Consenter) Ask(ctx context.Context, req Request) error { + hash, err := c.confirm(req) + if err != nil { + return err + } + + return c.Approvals.Approve(ctx, c.Tenant, req.ID, hash) +} + +// ConsentFunc returns the function for store.Options.Consent. It asks nothing +// when the new permissions are already approved on the tenant, and otherwise +// asks the person. It does not record the approval, because the install can +// still fail: after the install or update, call Record with the results of the +// plugins that succeeded. The approval is for the tenant and the version that the +// person was asked about. Calling the function again for a plugin forgets what +// the person approved for it before. +func (c *Consenter) ConsentFunc() store.ConsentFunc { + return func(_ context.Context, change store.Change) error { + delete(c.granted, change.ID) + + tenant, err := foldTenant(c.Tenant) + if err != nil { + return err + } + + hash := permissionsHash(change.New, change.NewCommands) + + approved, err := c.Approvals.Approved(c.Tenant, change.ID, hash) + if err != nil { + return err + } + + if approved { + return nil + } + + req := Request{ID: change.ID, Version: change.NewVersion, New: change.New, NewCommands: change.NewCommands} + if change.OldVersion != "" { + req.Old, req.OldCommands = &change.Old, change.OldCommands + } + + if _, err = c.confirm(req); err != nil { + return err + } + + if c.granted == nil { + c.granted = map[string]grant{} + } + + c.granted[change.ID] = grant{tenant: tenant, version: change.NewVersion, hash: hash} + + return nil + } +} + +// Record writes the approvals that the person gave through ConsentFunc, for the +// results that Install or Update returned. An approval is written only for a +// result with the same plugin ID and the same version as the one the person was +// asked about, because another process can change the plugin while the person +// answers, and the version that ends up installed is then not the one that was +// approved. For store.UpdateAll, pass the Result of each entry that has no error +// and was not skipped. A result that is empty, for another version, or for a +// plugin that was not approved is ignored. Every other approval that +// ConsentFunc collected is discarded, so a plugin that failed to install is +// never approved, not even by a later call. It does nothing when there is +// nothing to write. +func (c *Consenter) Record(ctx context.Context, installed ...store.Result) error { + granted := c.granted + c.granted = nil + + var errs []error + + for _, result := range installed { + given, found := granted[result.ID] + if !found || given.version != result.Version { + continue + } + + delete(granted, result.ID) + + errs = append(errs, c.Approvals.Approve(ctx, given.tenant, result.ID, given.hash)) + } + + return errors.Join(errs...) +} + +// permissionsHash returns the consent hash of what the person is shown. +func permissionsHash(permissions plugins.Permissions, commands []plugins.ConsentCommand) string { + return plugins.ConsentHash(plugins.NormalizePermissions(permissions), commands) +} + +// confirm shows the request and returns the consent hash of it when the person +// says yes. It is the only place that decides whether a person is there, and +// whether the screen shows the full permissions or only the change since the +// installed version, which it does only when the tenant approved that version and +// the change has something to show. +func (c *Consenter) confirm(req Request) (string, error) { + switch { + case c.Session.Agent: + return "", ErrAgentMode + case !c.Session.Interactive: + return "", ErrNotInteractive + } + + hash := permissionsHash(req.New, req.NewCommands) + + tenant, err := checkApproval(c.Tenant, req.ID, hash) + if err != nil { + return "", err + } + + // The installed version is not known to be approved on this tenant, so a + // difference to it would not tell the person what they are approving: they + // see everything. + if req.Old != nil { + known, err := c.Approvals.Approved(c.Tenant, req.ID, permissionsHash(*req.Old, req.OldCommands)) + if err != nil { + return "", err + } + + if !known { + req.Old, req.OldCommands = nil, nil + } + } + + // A difference that shows no item would tell the person nothing about what they + // approve, so they see everything. + if req.Old != nil && !showsChange(req) { + req.Old, req.OldCommands = nil, nil + } + + // A blank line sets the screen off from the lines the caller printed before it, + // such as the name and version of the plugin. + if _, err := io.WriteString(c.Out, "\n"+render(tenant, req)); err != nil { + return "", fmt.Errorf("failed to show the permissions: %w", err) + } + + // The person can ask for the exact scopes once, and only when there are some + // to show. + exact := renderExactScopes(req) + + plain := fmt.Sprintf("Approve for %s? [y/N] ", tenant) + + prompt := plain + if exact != "" { + prompt = fmt.Sprintf("Approve for %s? [y/N/d] (d shows the exact scopes) ", tenant) + } + + for { + line, err := c.Prompter.ReadLine(prompt) + if errors.Is(err, io.EOF) { + return "", ErrDeclined + } + + if err != nil { + return "", fmt.Errorf("%w (%w)", ErrDeclined, err) + } + + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return hash, nil + case "d": + if exact != "" { + if _, err := io.WriteString(c.Out, exact); err != nil { + return "", fmt.Errorf("failed to show the permissions: %w", err) + } + + exact, prompt = "", plain + + continue + } + } + + return "", ErrDeclined + } +} diff --git a/internal/plugins/approvals/consent_test.go b/internal/plugins/approvals/consent_test.go new file mode 100644 index 000000000..1b5a7c01a --- /dev/null +++ b/internal/plugins/approvals/consent_test.go @@ -0,0 +1,373 @@ +package approvals_test + +import ( + "bytes" + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/approvals" + "github.com/auth0/auth0-cli/internal/plugins/store" +) + +// answers is a Prompter that gives the lines it was given, and then io.EOF. +type answers struct { + lines []string + err error + prompts []string +} + +func (a *answers) ReadLine(prompt string) (string, error) { + a.prompts = append(a.prompts, prompt) + + if a.err != nil { + return "", a.err + } + + if len(a.lines) == 0 { + return "", io.EOF + } + + line := a.lines[0] + a.lines = a.lines[1:] + + return line, nil +} + +var ( + person = approvals.Session{Interactive: true} + + permissions = plugins.Permissions{ + Scopes: []string{"read:clients", "read:connections", "read:tenant_settings", "update:clients", "update:connections"}, + Hosts: []plugins.HostPermission{{Host: "api.github.com", Reason: "Check SDK versions"}}, + Files: []plugins.FilePermission{{Path: "$CWD", Access: plugins.FileAccessWrite, Reason: "Save the report"}}, + Env: []plugins.EnvPermission{{Name: "GITHUB_TOKEN", Reason: "Call the GitHub API"}}, + } + commands = []plugins.ConsentCommand{{Name: "checkmate", Interactive: false}} + request = approvals.Request{ID: id, Version: "1.0.0", New: permissions, NewCommands: commands} + + // The consent hash of the permissions and commands above. + consentHash = plugins.ConsentHash(permissions, commands) +) + +// newConsenter returns a consenter whose person gives the lines. +func newConsenter(t *testing.T, session approvals.Session, lines ...string) (*approvals.Consenter, *answers, *bytes.Buffer) { + t.Helper() + + prompter, out := &answers{lines: lines}, &bytes.Buffer{} + + return &approvals.Consenter{ + Tenant: tenant, + Approvals: newApprovals(t), + Session: session, + Prompter: prompter, + Out: out, + }, prompter, out +} + +// installed returns the results of installing version 1.0.0 of the plugins. +func installed(ids ...string) []store.Result { + results := make([]store.Result, 0, len(ids)) + for _, pluginID := range ids { + results = append(results, store.Result{ID: pluginID, Version: "1.0.0", Action: store.ActionInstalled}) + } + + return results +} + +func approved(t *testing.T, c *approvals.Consenter, hash string) bool { + t.Helper() + + ok, err := c.Approvals.Approved(tenant, id, hash) + require.NoError(t, err) + + return ok +} + +func TestAskRefusesWithoutAPerson(t *testing.T) { + tests := []struct { + name string + session approvals.Session + want error + }{ + {name: "zero session", session: approvals.Session{}, want: approvals.ErrNotInteractive}, + {name: "agent without a terminal", session: approvals.Session{Agent: true}, want: approvals.ErrAgentMode}, + {name: "agent at a terminal", session: approvals.Session{Interactive: true, Agent: true}, want: approvals.ErrAgentMode}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for name, ask := range map[string]func(*approvals.Consenter) error{ + "Ask": func(c *approvals.Consenter) error { return c.Ask(t.Context(), request) }, + "ConsentFunc": func(c *approvals.Consenter) error { + return c.ConsentFunc()(t.Context(), store.Change{ID: id, NewVersion: "1.0.0", New: permissions, NewCommands: commands}) + }, + } { + c, prompter, out := newConsenter(t, test.session, "y") + + require.ErrorIs(t, ask(c), test.want, name) + assert.Empty(t, prompter.prompts, name) + assert.Zero(t, out.Len(), name) + require.NoError(t, c.Record(t.Context())) + + _, err := os.Stat(c.Approvals.Dir) + assert.ErrorIs(t, err, os.ErrNotExist, name) + } + }) + } + + t.Run("an invalid tenant is refused before anything is shown", func(t *testing.T) { + c, prompter, out := newConsenter(t, person, "y") + c.Tenant = "acme/evil" + + require.Error(t, c.Ask(t.Context(), request)) + assert.Empty(t, prompter.prompts) + assert.Zero(t, out.Len()) + }) +} + +func TestAskAnswers(t *testing.T) { + tests := []struct { + name string + lines []string + err error + approve bool + }{ + {name: "y", lines: []string{"y"}, approve: true}, + {name: "upper case Y", lines: []string{"Y"}, approve: true}, + {name: "yes", lines: []string{"yes"}, approve: true}, + {name: "YES with spaces", lines: []string{" YES \n"}, approve: true}, + {name: "n", lines: []string{"n"}}, + {name: "empty line", lines: []string{""}}, + {name: "maybe", lines: []string{"maybe"}}, + {name: "yy", lines: []string{"yy"}}, + {name: "end of input", lines: nil}, + {name: "read error", err: errors.New("terminal closed")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c, prompter, _ := newConsenter(t, person, test.lines...) + prompter.err = test.err + + err := c.Ask(t.Context(), request) + + if test.approve { + require.NoError(t, err) + assert.True(t, approved(t, c, consentHash)) + assert.Equal(t, []string{"Approve for acme.us.auth0.com? [y/N/d] (d shows the exact scopes) "}, prompter.prompts) + + return + } + + require.ErrorIs(t, err, approvals.ErrDeclined) + assert.False(t, approved(t, c, consentHash)) + + _, statErr := os.Stat(c.Approvals.Dir) + assert.ErrorIs(t, statErr, os.ErrNotExist, "a declined request writes nothing") + + if test.err != nil { + assert.ErrorIs(t, err, test.err) + } + }) + } + + t.Run("d shows the exact scopes without the screen and asks again", func(t *testing.T) { + c, prompter, out := newConsenter(t, person, "d", "y") + + require.NoError(t, c.Ask(t.Context(), request)) + assert.True(t, approved(t, c, consentHash)) + + assert.Equal(t, []string{ + "Approve for acme.us.auth0.com? [y/N/d] (d shows the exact scopes) ", + "Approve for acme.us.auth0.com? [y/N] ", + }, prompter.prompts) + assert.Equal(t, 1, strings.Count(out.String(), "This plugin asks to use your Auth0 tenant"), "the screen is printed once") + assert.True(t, strings.HasSuffix(out.String(), "\n Exact scopes read:clients, read:connections, read:tenant_settings,\n"+ + " update:clients, update:connections\n\n")) + }) +} + +func TestAskApprovesWhatWasShown(t *testing.T) { + c, _, _ := newConsenter(t, person, "y") + + unsorted := request + unsorted.New.Scopes = []string{"update:connections", "read:clients", "read:tenant_settings", "update:clients", "read:connections"} + + require.NoError(t, c.Ask(t.Context(), unsorted)) + + assert.True(t, approved(t, c, consentHash)) +} + +func TestConsentFunc(t *testing.T) { + change := store.Change{ID: id, NewVersion: "1.0.0", New: permissions, NewCommands: commands} + + t.Run("an approved hash needs no person and no prompt", func(t *testing.T) { + c, prompter, out := newConsenter(t, approvals.Session{}) + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, consentHash)) + + require.NoError(t, c.ConsentFunc()(t.Context(), change)) + assert.Empty(t, prompter.prompts) + assert.Zero(t, out.Len()) + }) + + t.Run("a pinned hash needs no person and no prompt", func(t *testing.T) { + c, prompter, out := newConsenter(t, approvals.Session{Agent: true}) + c.Approvals.Pins = approvals.Pins{id: {consentHash}} + + require.NoError(t, c.ConsentFunc()(t.Context(), change)) + assert.Empty(t, prompter.prompts) + assert.Zero(t, out.Len()) + }) + + t.Run("a damaged file stops the install", func(t *testing.T) { + c, prompter, _ := newConsenter(t, person, "y") + writeFile(t, c.Approvals, "not json") + + require.ErrorIs(t, c.ConsentFunc()(t.Context(), change), approvals.ErrDamaged) + assert.Empty(t, prompter.prompts) + }) + + t.Run("the approval is written by Record and not before", func(t *testing.T) { + c, _, _ := newConsenter(t, person, "y") + + require.NoError(t, c.ConsentFunc()(t.Context(), change)) + assert.False(t, approved(t, c, consentHash), "a failed install must leave no approval") + + require.NoError(t, c.Record(t.Context(), installed(id)...)) + assert.True(t, approved(t, c, consentHash)) + + records, err := c.Approvals.List(id) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, consentHash, records[0].PermissionsHash) + }) + + t.Run("a decline stops the install and records nothing", func(t *testing.T) { + c, _, _ := newConsenter(t, person, "n") + + require.ErrorIs(t, c.ConsentFunc()(t.Context(), change), approvals.ErrDeclined) + require.NoError(t, c.Record(t.Context(), installed(id)...)) + assert.False(t, approved(t, c, consentHash)) + }) + + t.Run("an earlier yes does not outlive a later no for the same plugin", func(t *testing.T) { + c, _, _ := newConsenter(t, person, "y", "n") + consent := c.ConsentFunc() + + require.NoError(t, consent(t.Context(), change)) + require.ErrorIs(t, consent(t.Context(), change), approvals.ErrDeclined) + require.NoError(t, c.Record(t.Context(), installed(id)...)) + assert.False(t, approved(t, c, consentHash)) + }) + + t.Run("Record writes each approval once", func(t *testing.T) { + c, _, _ := newConsenter(t, person, "y") + require.NoError(t, c.ConsentFunc()(t.Context(), change)) + require.NoError(t, c.Record(t.Context(), installed(id)...)) + require.NoError(t, c.Approvals.RemoveAll(id)) + require.NoError(t, c.Record(t.Context(), installed(id)...)) + + assert.False(t, approved(t, c, consentHash)) + }) + + t.Run("Record approves only the plugins it names and discards the others", func(t *testing.T) { + other := change + other.ID = "auth0/other" + + granted := func(t *testing.T, c *approvals.Consenter) { + t.Helper() + + consent := c.ConsentFunc() + require.NoError(t, consent(t.Context(), change)) + require.NoError(t, consent(t.Context(), other)) + } + + tests := []struct { + name string + results []store.Result + wantFirst bool + wantSecond bool + }{ + {name: "no results"}, + {name: "a plugin that was not granted", results: installed("auth0/unknown")}, + {name: "one of two", results: installed(id), wantFirst: true}, + {name: "the other one, with an unknown ID and a repeat", results: installed("auth0/unknown", other.ID, other.ID), wantSecond: true}, + {name: "both", results: installed(other.ID, id), wantFirst: true, wantSecond: true}, + {name: "the same ID at another version", results: []store.Result{{ID: id, Version: "1.0.1"}, {ID: other.ID, Version: "1.0.0"}}, wantSecond: true}, + {name: "an empty result, as a skipped or failed update has", results: []store.Result{{}}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c, _, _ := newConsenter(t, person, "y", "y") + granted(t, c) + + require.NoError(t, c.Record(t.Context(), test.results...)) + require.NoError(t, c.Record(t.Context(), installed(id, other.ID)...), "what was not named is gone for good") + + first, err := c.Approvals.Approved(tenant, id, consentHash) + require.NoError(t, err) + assert.Equal(t, test.wantFirst, first) + + second, err := c.Approvals.Approved(tenant, other.ID, consentHash) + require.NoError(t, err) + assert.Equal(t, test.wantSecond, second) + }) + } + }) + + t.Run("Record writes for the tenant the person was asked about", func(t *testing.T) { + c, _, _ := newConsenter(t, person, "y") + require.NoError(t, c.ConsentFunc()(t.Context(), change)) + + c.Tenant = "other.us.auth0.com" + require.NoError(t, c.Record(t.Context(), installed(id)...)) + + assert.True(t, approved(t, c, consentHash), "the tenant of the grant") + + ok, err := c.Approvals.Approved("other.us.auth0.com", id, consentHash) + require.NoError(t, err) + assert.False(t, ok, "the tenant of Record") + }) + + t.Run("Record with nothing to write creates nothing", func(t *testing.T) { + c, _, _ := newConsenter(t, person) + + require.NoError(t, c.Record(t.Context())) + + _, err := os.Stat(c.Approvals.Dir) + assert.ErrorIs(t, err, os.ErrNotExist) + }) + + update := change + update.OldVersion = "0.9.0" + update.Old = plugins.Permissions{Scopes: permissions.Scopes[:4], Hosts: permissions.Hosts, Files: permissions.Files, Env: permissions.Env} + update.OldCommands = commands + + t.Run("an update of a version the tenant approved shows the difference", func(t *testing.T) { + c, _, out := newConsenter(t, person, "y") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(update.Old), commands))) + + require.NoError(t, c.ConsentFunc()(t.Context(), update)) + + assert.Contains(t, out.String(), "auth0/checkmate 1.0.0 asks for different permissions on tenant acme.us.auth0.com") + assert.Contains(t, out.String(), "Added can change connections (update)") + assert.NotContains(t, out.String(), "api.github.com") + }) + + t.Run("an update of a version the tenant never approved shows everything", func(t *testing.T) { + c, _, out := newConsenter(t, person, "y") + + require.NoError(t, c.ConsentFunc()(t.Context(), update)) + + assert.Contains(t, out.String(), "This plugin asks to use your Auth0 tenant acme.us.auth0.com") + assert.Contains(t, out.String(), "api.github.com") + }) +} diff --git a/internal/plugins/approvals/errors.go b/internal/plugins/approvals/errors.go new file mode 100644 index 000000000..0be531779 --- /dev/null +++ b/internal/plugins/approvals/errors.go @@ -0,0 +1,64 @@ +package approvals + +import ( + "fmt" + + "github.com/auth0/auth0-cli/internal/plugins" +) + +// approvalsError is an error that is a constant, so that the sentinel errors +// below cannot be changed. The messages of the sentinels are sentences that are +// shown to users as they are. +type approvalsError string + +// Error returns the message of the error. +func (e approvalsError) Error() string { + return string(e) +} + +// The errors that approving and checking plugins can return. Use errors.Is to +// tell them apart, as some of them wrap more detail. +const ( + // ErrDamaged means approvals.json cannot be trusted: it cannot be read, is + // too large, is not a regular file, or does not follow the format. The file + // is never replaced automatically, because that would drop approvals + // without telling the user. + ErrDamaged = approvalsError("The plugin approvals file is damaged.") + + // ErrAgentMode means an agent tried to approve a plugin. Only a person can. + ErrAgentMode = approvalsError("A plugin cannot be approved in agent mode. Approve it in a terminal, or pin it with " + PinsEnv + ".") + + // ErrNotInteractive means the session has no person to ask. Only a person + // can approve a plugin. + ErrNotInteractive = approvalsError("A plugin can only be approved in an interactive terminal. Approve it in a terminal, or pin it with " + PinsEnv + ".") + + // ErrDeclined means the person did not approve the plugin. + ErrDeclined = approvalsError("The plugin was not approved.") + + // ErrBadPins means the value of PinsEnv is not a list of plugin pins. + ErrBadPins = approvalsError("The value of " + PinsEnv + " is not valid.") + + // ErrBusy means another auth0 process is changing the approvals and did not + // finish in time. + ErrBusy = approvalsError("Another auth0 process is changing the plugin approvals.") +) + +// NotApprovedError means a plugin is not approved to run on a tenant. +type NotApprovedError struct { + Tenant string + ID string + + // Changed is true when the tenant approved another permission set of the + // plugin earlier, so the plugin now asks for something that was not approved. + Changed bool +} + +// Error returns the message and the command that approves the plugin. +func (e *NotApprovedError) Error() string { + state := "is not approved for " + e.Tenant + if e.Changed { + state = "changed what it asks for since you approved it for " + e.Tenant + } + + return fmt.Sprintf("%s %s. Run: auth0 plugin approve %s --tenant %s", plugins.NameOf(e.ID), state, e.ID, e.Tenant) +} diff --git a/internal/plugins/approvals/errors_test.go b/internal/plugins/approvals/errors_test.go new file mode 100644 index 000000000..7a209bb3d --- /dev/null +++ b/internal/plugins/approvals/errors_test.go @@ -0,0 +1,34 @@ +package approvals_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/plugins/approvals" +) + +func TestNotApprovedError(t *testing.T) { + tests := []struct { + name string + err approvals.NotApprovedError + want string + }{ + { + name: "never approved", + err: approvals.NotApprovedError{Tenant: "acme.us.auth0.com", ID: "auth0/checkmate"}, + want: "checkmate is not approved for acme.us.auth0.com. Run: auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com", + }, + { + name: "approved with other permissions", + err: approvals.NotApprovedError{Tenant: "acme.us.auth0.com", ID: "auth0/checkmate", Changed: true}, + want: "checkmate changed what it asks for since you approved it for acme.us.auth0.com. Run: auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.EqualError(t, &test.err, test.want) + }) + } +} diff --git a/internal/plugins/approvals/file.go b/internal/plugins/approvals/file.go new file mode 100644 index 000000000..51d780bc3 --- /dev/null +++ b/internal/plugins/approvals/file.go @@ -0,0 +1,298 @@ +package approvals + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "runtime" + "time" + + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/filelock" +) + +const ( + // The name of the approvals file in the plugins directory. + fileName = "approvals.json" + + // The name of the lock file that writers take. It is not the lock of the + // store, because the store holds that one while it removes a plugin, which + // calls RemoveAll. + lockName = ".approvals.lock" + + // The only format of the approvals file this CLI reads. + formatVersion = 1 + + // The largest approvals file that is read or written. + maxFileBytes = 1 << 20 + + // The permissions of the plugins directory, which only the current user + // needs. + dirPerm os.FileMode = 0o700 +) + +var ( + // The form of a tenant domain before it is folded to lower case. It only + // matches ASCII, so that folding cannot map another character onto a letter. + tenantPattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$`) + + // The form of a plugin ID, registry/name. It is the pattern that the store + // uses. + idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*$`) + + hashPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +) + +// renameFile replaces a file, which tests replace to make it fail. +var renameFile = os.Rename + +// approval is what is stored for one plugin on one tenant. +type approval struct { + PermissionsHash string `json:"permissionsHash"` + ApprovedAt time.Time `json:"approvedAt"` +} + +// table maps a tenant to a plugin ID to what was approved. +type table map[string]map[string]approval + +// document is the content of approvals.json. +type document struct { + FormatVersion int `json:"formatVersion"` + Tenants table `json:"tenants"` +} + +// path returns the location of the approvals file. +func (a *Approvals) path() string { + return filepath.Join(a.Dir, fileName) +} + +// checkDir returns an error unless the plugins directory is an absolute path. A +// relative or empty one would read and write approvals.json in whatever +// directory the command happens to run in. +func (a *Approvals) checkDir() error { + if !filepath.IsAbs(a.Dir) { + return fmt.Errorf("the plugins directory %q is not an absolute path", a.Dir) + } + + return nil +} + +// load reads the approvals. A missing file is an empty table. Anything else that +// is not a valid approvals file is an error that wraps ErrDamaged, and the file +// is left alone. +// +// It takes no lock, because the file is always replaced by a rename. +func (a *Approvals) load() (table, error) { + if err := a.checkDir(); err != nil { + return nil, err + } + + data, err := readRegularFile(a.path()) + if errors.Is(err, fs.ErrNotExist) { + return table{}, nil + } + + if err != nil { + return nil, a.damaged(err) + } + + loaded, err := parse(data) + if err != nil { + return nil, a.damaged(err) + } + + return loaded, nil +} + +// damaged wraps the reason that the file cannot be used. +func (a *Approvals) damaged(reason error) error { + return fmt.Errorf("%w (%s: %w), remove the file to start over, which asks for approval again", ErrDamaged, a.path(), reason) +} + +// readRegularFile reads a file that must be a regular file and not a link, that +// other users cannot write to, and that is not larger than maxFileBytes. It +// looks at the path first and compares it with what was opened, so a file +// swapped for a link in between is refused too. +func readRegularFile(path string) ([]byte, error) { + before, err := os.Lstat(path) + if err != nil { + return nil, err + } + + if !before.Mode().IsRegular() { + return nil, errors.New("not a regular file") + } + + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + after, err := file.Stat() + if err != nil { + return nil, err + } + + if !os.SameFile(before, after) { + return nil, errors.New("changed while it was opened") + } + + if runtime.GOOS != "windows" && after.Mode().Perm()&0o022 != 0 { + return nil, errors.New("writable by other users") + } + + return plugins.ReadCapped(file, maxFileBytes) +} + +// parse decodes and validates the content of the approvals file. Every key must +// be exactly one that the format has, in exactly that case, because the decoder +// of encoding/json would accept other cases and the keys of the file decide +// what is approved. +func parse(data []byte) (table, error) { + var top map[string]json.RawMessage + + if err := plugins.UnmarshalStrict(data, &top); err != nil { + return nil, err + } + + if err := checkKeys(top, "formatVersion", "tenants"); err != nil { + return nil, err + } + + var version int + + if err := json.Unmarshal(top["formatVersion"], &version); err != nil || version != formatVersion { + return nil, fmt.Errorf("the format version is not %d, which is the only one this CLI reads", formatVersion) + } + + var tenants map[string]map[string]json.RawMessage + + if err := json.Unmarshal(top["tenants"], &tenants); err != nil { + return nil, fmt.Errorf("tenants: %w", err) + } + + // A null decodes without an error, to a map that is nil. + if tenants == nil { + return nil, errors.New("tenants: not an object") + } + + loaded := table{} + + for tenant, plugs := range tenants { + if folded, err := foldTenant(tenant); err != nil || folded != tenant { + return nil, fmt.Errorf("tenants[%q]: not a lower case tenant domain", tenant) + } + + if plugs == nil { + return nil, fmt.Errorf("tenants[%q]: not an object", tenant) + } + + loaded[tenant] = map[string]approval{} + + for id, raw := range plugs { + stored, err := parseApproval(id, raw) + if err != nil { + return nil, fmt.Errorf("tenants[%q][%q]: %w", tenant, id, err) + } + + loaded[tenant][id] = stored + } + } + + return loaded, nil +} + +// parseApproval decodes and validates one approval, which is stored under id. +func parseApproval(id string, raw json.RawMessage) (approval, error) { + if !idPattern.MatchString(id) { + return approval{}, errors.New("the key is not a plugin ID in the form registry/name") + } + + var fields map[string]json.RawMessage + + if err := json.Unmarshal(raw, &fields); err != nil { + return approval{}, err + } + + if err := checkKeys(fields, "permissionsHash", "approvedAt"); err != nil { + return approval{}, err + } + + var stored approval + + if err := json.Unmarshal(fields["permissionsHash"], &stored.PermissionsHash); err != nil || !hashPattern.MatchString(stored.PermissionsHash) { + return approval{}, errors.New("permissionsHash is not sha256: followed by a lowercase hex SHA-256") + } + + if err := json.Unmarshal(fields["approvedAt"], &stored.ApprovedAt); err != nil { + return approval{}, fmt.Errorf("approvedAt: %w", err) + } + + return stored, nil +} + +// checkKeys returns an error unless the object has the named keys, in exactly +// that case, and no other key. +func checkKeys(fields map[string]json.RawMessage, names ...string) error { + for _, name := range names { + if _, found := fields[name]; !found { + return fmt.Errorf("the key %q is missing, or is written in another case", name) + } + } + + if len(fields) != len(names) { + return fmt.Errorf("the object has keys other than %q", names) + } + + return nil +} + +// lock creates the plugins directory when it does not exist, takes the lock that +// writers share, and returns the function that frees it. +func (a *Approvals) lock(ctx context.Context) (release func(), err error) { + if err := a.checkDir(); err != nil { + return nil, err + } + + if err := os.MkdirAll(a.Dir, dirPerm); err != nil { + return nil, fmt.Errorf("failed to create the plugins directory: %w", err) + } + + return filelock.Acquire(ctx, filepath.Join(a.Dir, lockName), ErrBusy) +} + +// write replaces the approvals file with t, through a temporary file that is +// synced and then renamed, so that readers see either the old file or the new +// one, never a partial file. The caller must hold the lock, and must have loaded +// the table it changed under that lock. +func (a *Approvals) write(t table) error { + data, err := json.MarshalIndent(document{FormatVersion: formatVersion, Tenants: t}, "", " ") + if err != nil { + return fmt.Errorf("failed to encode the plugin approvals: %w", err) + } + + if len(data) > maxFileBytes { + return fmt.Errorf("failed to write the plugin approvals: %w", plugins.ErrTooLarge) + } + + temp, err := plugins.StageTempFile(a.Dir, fileName, data) + if err != nil { + return fmt.Errorf("failed to write the plugin approvals: %w", err) + } + + if err := renameFile(temp, a.path()); err != nil { + _ = os.Remove(temp) + + return fmt.Errorf("failed to write the plugin approvals: %w", err) + } + + plugins.SyncDir(a.Dir) + + return nil +} diff --git a/internal/plugins/approvals/file_test.go b/internal/plugins/approvals/file_test.go new file mode 100644 index 000000000..b29a9a57e --- /dev/null +++ b/internal/plugins/approvals/file_test.go @@ -0,0 +1,152 @@ +package approvals_test + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins/approvals" +) + +// document returns an approvals file with one approval, with the given pieces +// replaced by name: "version", "tenant", "id", "hash" and "extra". +func document(replace map[string]string) string { + pieces := map[string]string{ + "version": `"formatVersion": 1`, + "tenant": `"` + tenant + `"`, + "id": `"` + id + `"`, + "hash": `"permissionsHash": "` + hashA + `"`, + "time": `"approvedAt": "2026-05-01T12:00:00Z"`, + "extra": ``, + } + + for name, piece := range replace { + pieces[name] = piece + } + + return `{` + pieces["version"] + `, "tenants": {` + pieces["tenant"] + `: {` + pieces["id"] + + `: {` + pieces["hash"] + `, ` + pieces["time"] + pieces["extra"] + `}}}}` +} + +func TestApprovalsFile(t *testing.T) { + t.Run("a valid file is read", func(t *testing.T) { + a := newApprovals(t) + writeFile(t, a, document(nil)) + + require.NoError(t, a.Check(tenant, id, hashA)) + }) + + damaged := []struct { + name string + write func(t *testing.T, path string) + }{ + {name: "garbage", write: content("this is not json")}, + {name: "empty file", write: content("")}, + {name: "trailing data", write: content(document(nil) + " {}")}, + {name: "duplicate key", write: content(strings.Replace(document(nil), `"formatVersion": 1`, `"formatVersion": 1, "formatVersion": 1`, 1))}, + {name: "duplicate tenant", write: content(`{"formatVersion": 1, "tenants": {"` + tenant + `": {}, "` + tenant + `": {}}}`)}, + {name: "case variant of a top level key", write: content(strings.Replace(document(nil), `"tenants"`, `"Tenants"`, 1))}, + {name: "case variant of a field", write: content(document(map[string]string{"hash": `"PermissionsHash": "` + hashA + `"`}))}, + {name: "unknown field", write: content(document(map[string]string{"extra": `, "note": "x"`}))}, + {name: "wrong format version", write: content(document(map[string]string{"version": `"formatVersion": 2`}))}, + {name: "format version as text", write: content(document(map[string]string{"version": `"formatVersion": "1"`}))}, + {name: "null tenants", write: content(`{"formatVersion": 1, "tenants": null}`)}, + {name: "tenants as text", write: content(`{"formatVersion": 1, "tenants": "x"}`)}, + {name: "tenants as a list", write: content(`{"formatVersion": 1, "tenants": []}`)}, + {name: "null tenant entry", write: content(`{"formatVersion": 1, "tenants": {"` + tenant + `": null}}`)}, + {name: "null approval", write: content(`{"formatVersion": 1, "tenants": {"` + tenant + `": {"` + id + `": null}}}`)}, + {name: "bad hash", write: content(document(map[string]string{"hash": `"permissionsHash": "sha256:abc"`}))}, + {name: "upper case hash", write: content(document(map[string]string{"hash": `"permissionsHash": "sha256:` + upper + `"`}))}, + {name: "bad time", write: content(document(map[string]string{"time": `"approvedAt": "yesterday"`}))}, + {name: "upper case tenant", write: content(document(map[string]string{"tenant": `"ACME.us.auth0.com"`}))}, + {name: "invalid plugin ID", write: content(document(map[string]string{"id": `"checkmate"`}))}, + {name: "larger than 1 MiB", write: content(document(nil) + strings.Repeat(" ", 1<<20))}, + {name: "a symlink to a valid file", write: func(t *testing.T, path string) { + target := filepath.Join(t.TempDir(), "target.json") + require.NoError(t, os.WriteFile(target, []byte(document(nil)), 0o600)) + + if err := os.Symlink(target, path); err != nil { + t.Skipf("symlinks are not available: %v", err) + } + }}, + {name: "a directory", write: func(t *testing.T, path string) { + require.NoError(t, os.Mkdir(path, 0o700)) + }}, + {name: "writable by other users", write: func(t *testing.T, path string) { + if runtime.GOOS == "windows" { + t.Skip("Windows has no permission bits to check") + } + + require.NoError(t, os.WriteFile(path, []byte(document(nil)), 0o600)) + require.NoError(t, os.Chmod(path, 0o666)) + }}, + } + + for _, test := range damaged { + t.Run(test.name+" is damaged for every reader and kept", func(t *testing.T) { + a := newApprovals(t) + require.NoError(t, os.MkdirAll(a.Dir, 0o700)) + + path := filepath.Join(a.Dir, "approvals.json") + test.write(t, path) + + require.ErrorIs(t, a.Approve(t.Context(), tenant, id, hashB), approvals.ErrDamaged) + + err := a.Check(tenant, id, hashA) + require.ErrorIs(t, err, approvals.ErrDamaged) + assert.ErrorContains(t, err, path) + require.ErrorIs(t, errOf(a.Approved(tenant, id, hashA)), approvals.ErrDamaged) + require.ErrorIs(t, errOf(a.List(id)), approvals.ErrDamaged) + + _, err = os.Lstat(path) + assert.NoError(t, err, "the damaged file must stay") + }) + + t.Run(test.name+" is deleted by the remove functions", func(t *testing.T) { + for _, remove := range removers { + a := newApprovals(t) + require.NoError(t, os.MkdirAll(a.Dir, 0o700)) + + path := filepath.Join(a.Dir, "approvals.json") + test.write(t, path) + + require.NoError(t, remove(a)) + + _, err := os.Lstat(path) + assert.ErrorIs(t, err, os.ErrNotExist) + } + }) + } +} + +func TestRemoveKeepsADirectoryWithContent(t *testing.T) { + for name, remove := range removers { + t.Run(name, func(t *testing.T) { + a := newApprovals(t) + inside := filepath.Join(a.Dir, "approvals.json", "inside") + require.NoError(t, os.MkdirAll(inside, 0o700)) + + require.Error(t, remove(a), "a directory is never emptied for the person") + + _, err := os.Lstat(inside) + assert.NoError(t, err) + }) + } +} + +// content returns a writer that puts text in the file with the given path. +func content(text string) func(t *testing.T, path string) { + return func(t *testing.T, path string) { + require.NoError(t, os.WriteFile(path, []byte(text), 0o600)) + } +} + +// errOf returns the error of a function that also returns a value. +func errOf[T any](_ T, err error) error { + return err +} diff --git a/internal/plugins/approvals/internal_test.go b/internal/plugins/approvals/internal_test.go new file mode 100644 index 000000000..fc7ecf489 --- /dev/null +++ b/internal/plugins/approvals/internal_test.go @@ -0,0 +1,83 @@ +package approvals + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins" +) + +func TestFailedRenameKeepsTheOldFile(t *testing.T) { + const ( + tenant = "acme.us.auth0.com" + hashA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + hashB = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ) + + a := &Approvals{Dir: filepath.Join(t.TempDir(), "plugins")} + require.NoError(t, a.Approve(t.Context(), tenant, "auth0/checkmate", hashA)) + + before, err := os.ReadFile(a.path()) + require.NoError(t, err) + + namesBefore := dirEntries(t, a.Dir) + + renameFile = func(string, string) error { return errors.New("disk is read only") } + + t.Cleanup(func() { renameFile = os.Rename }) + + require.ErrorContains(t, a.Approve(t.Context(), tenant, "auth0/checkmate", hashB), "disk is read only") + + after, err := os.ReadFile(a.path()) + require.NoError(t, err) + + assert.Equal(t, string(before), string(after)) + assert.Equal(t, namesBefore, dirEntries(t, a.Dir), "no temporary file is left behind") +} + +func TestWriteRefusesATableOverTheCap(t *testing.T) { + const hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + a := &Approvals{Dir: filepath.Join(t.TempDir(), "plugins")} + require.NoError(t, a.Approve(t.Context(), "acme.us.auth0.com", "auth0/checkmate", hash)) + + before, err := os.ReadFile(a.path()) + require.NoError(t, err) + + namesBefore := dirEntries(t, a.Dir) + + // Every approval takes over 100 bytes of the file, so this is over the cap. + large := table{"acme.us.auth0.com": {}} + for n := range maxFileBytes / 100 { + large["acme.us.auth0.com"][fmt.Sprintf("auth0/plugin-%d", n)] = approval{PermissionsHash: hash} + } + + require.ErrorIs(t, a.write(large), plugins.ErrTooLarge) + + after, err := os.ReadFile(a.path()) + require.NoError(t, err) + + assert.Equal(t, string(before), string(after), "the old file is kept") + assert.Equal(t, namesBefore, dirEntries(t, a.Dir), "no temporary file is left behind") +} + +// dirEntries lists the names in a directory. +func dirEntries(t *testing.T, dir string) []string { + t.Helper() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + + return names +} diff --git a/internal/plugins/approvals/pins.go b/internal/plugins/approvals/pins.go new file mode 100644 index 000000000..5f8a3ed73 --- /dev/null +++ b/internal/plugins/approvals/pins.go @@ -0,0 +1,52 @@ +package approvals + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +// PinsEnv is the environment variable that approves plugins for CI. It holds a +// comma separated list of registry/name@sha256: entries. +const PinsEnv = "AUTH0_CLI_PLUGIN_APPROVALS" + +// pinPattern is the form of one entry, a plugin ID and a consent hash. +var pinPattern = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*)@(sha256:[0-9a-f]{64})$`) + +// Pins maps a plugin ID to the consent hashes that are approved for it on every +// tenant. A pin is never written to approvals.json. +type Pins map[string][]string + +// ParsePins parses the value of PinsEnv. Spaces around an entry are ignored, an +// empty value means there are no pins, and a plugin ID can be pinned to more +// than one hash. An entry in any other form, such as an empty one after a +// trailing comma, is an error that names it: a typo must never look like having +// no pins. +func ParsePins(value string) (Pins, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + + pins := Pins{} + + for entry := range strings.SplitSeq(value, ",") { + entry = strings.TrimSpace(entry) + + match := pinPattern.FindStringSubmatch(entry) + if match == nil { + return nil, fmt.Errorf("%w (the entry %q is not in the form registry/name@sha256:<64 lowercase hex digits>)", ErrBadPins, entry) + } + + if !slices.Contains(pins[match[1]], match[2]) { + pins[match[1]] = append(pins[match[1]], match[2]) + } + } + + return pins, nil +} + +// approves reports whether the plugin is pinned to the hash. +func (p Pins) approves(pluginID, hash string) bool { + return slices.Contains(p[pluginID], hash) +} diff --git a/internal/plugins/approvals/pins_test.go b/internal/plugins/approvals/pins_test.go new file mode 100644 index 000000000..3e75d5501 --- /dev/null +++ b/internal/plugins/approvals/pins_test.go @@ -0,0 +1,111 @@ +package approvals_test + +import ( + "errors" + "os" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins/approvals" +) + +func TestParsePins(t *testing.T) { + tests := []struct { + name string + value string + want approvals.Pins + }{ + {name: "one pin", value: id + "@" + hashA, want: approvals.Pins{id: {hashA}}}, + {name: "spaces around entries", value: " " + id + "@" + hashA + " ,\t" + "auth0/other@" + hashB + " ", want: approvals.Pins{id: {hashA}, "auth0/other": {hashB}}}, + {name: "more than one hash for an ID", value: id + "@" + hashA + "," + id + "@" + hashB, want: approvals.Pins{id: {hashA, hashB}}}, + {name: "the same pin twice", value: id + "@" + hashA + "," + id + "@" + hashA, want: approvals.Pins{id: {hashA}}}, + {name: "empty value", value: "", want: nil}, + {name: "only spaces", value: " ", want: nil}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pins, err := approvals.ParsePins(test.value) + + require.NoError(t, err) + assert.Equal(t, test.want, pins) + }) + } +} + +func TestParsePinsRejectsMalformedEntries(t *testing.T) { + tests := []struct { + name string + value string + entry string // The quoted entry that the error names, which is the whole value when empty. + }{ + {name: "uppercase hex", value: id + "@sha256:" + upper}, + {name: "missing @", value: id + hashA}, + {name: "name without registry", value: "checkmate@" + hashA}, + {name: "uppercase ID", value: "Auth0/checkmate@" + hashA}, + {name: "truncated hash", value: id + "@" + hashA[:len(hashA)-1]}, + {name: "no hash prefix", value: id + "@" + hashA[len("sha256:"):]}, + {name: "trailing comma", value: id + "@" + hashA + ",", entry: `""`}, + {name: "one bad entry among good ones", value: id + "@" + hashA + ", nonsense", entry: `"nonsense"`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pins, err := approvals.ParsePins(test.value) + + entry := test.entry + if entry == "" { + entry = strconv.Quote(test.value) + } + + require.ErrorIs(t, err, approvals.ErrBadPins) + assert.ErrorContains(t, err, entry) + assert.Nil(t, pins) + }) + } +} + +func TestPins(t *testing.T) { + t.Run("a pin approves on any tenant with no file, and writes none", func(t *testing.T) { + a := newApprovals(t) + a.Pins = approvals.Pins{id: {hashA}} + + for _, name := range []string{tenant, "other.eu.auth0.com"} { + approved, err := a.Approved(name, id, hashA) + + require.NoError(t, err) + assert.True(t, approved) + require.NoError(t, a.Check(name, id, hashA)) + } + + _, err := os.Stat(a.Dir) + assert.ErrorIs(t, err, os.ErrNotExist) + }) + + t.Run("a pin with another hash approves nothing", func(t *testing.T) { + a := newApprovals(t) + a.Pins = approvals.Pins{id: {hashB}, "auth0/other": {hashA}} + + approved, err := a.Approved(tenant, id, hashA) + + require.NoError(t, err) + assert.False(t, approved) + + var notApproved *approvals.NotApprovedError + + require.True(t, errors.As(a.Check(tenant, id, hashA), ¬Approved)) + assert.False(t, notApproved.Changed) + }) + + t.Run("a pin approves although the approvals file is damaged", func(t *testing.T) { + a := newApprovals(t) + a.Pins = approvals.Pins{id: {hashA}} + writeFile(t, a, "not json") + + require.NoError(t, a.Check(tenant, id, hashA)) + require.ErrorIs(t, a.Check(tenant, id, hashB), approvals.ErrDamaged) + }) +} diff --git a/internal/plugins/approvals/render.go b/internal/plugins/approvals/render.go new file mode 100644 index 000000000..d3661ccdb --- /dev/null +++ b/internal/plugins/approvals/render.go @@ -0,0 +1,432 @@ +package approvals + +import ( + "fmt" + "maps" + "slices" + "strings" + "unicode/utf8" + + "github.com/auth0/auth0-cli/internal/plugins" +) + +const ( + // The width of the label column of the screen. + labelWidth = 12 + + // The width of what comes before the value of a row: the indent of two columns, + // the label column and the space after it. + indentWidth = 2 + labelWidth + 1 + + // The most columns that a line of the exact scopes takes. + maxLineWidth = 80 + + // The most runes of one value from a manifest that the screen shows. + maxValueRunes = 200 + + // The line that tells the person what the CLI does not check. + notEnforced = "Each command runs with only the permissions it needs. Hosts and files are declared by the publisher and are not enforced by the CLI." +) + +// screen builds a consent screen. +type screen struct { + text strings.Builder + + // How many rows were added. + rows int +} + +// row adds one line with a label and a value. The value is cleaned so that text +// from a manifest cannot move the cursor or rewrite earlier lines. +func (s *screen) row(label, value string) { + fmt.Fprintf(&s.text, " %-*s %s\n", labelWidth, label, plugins.SafeText(value)) + + s.rows++ +} + +// wrapped adds the rows of a list of items that are shown as they are, with the +// label on the first row only. The items are separated by commas and the list +// wraps between two items, so that a line takes at most maxLineWidth columns and +// no item is split. An item that is wider than a line is alone on its line. +func (s *screen) wrapped(label string, items []string) { + width := maxLineWidth - indentWidth + + var line string + + for n, item := range items { + item = plugins.SafeText(item) + if n < len(items)-1 { + item += "," + } + + switch { + case line == "": + line = item + case utf8.RuneCountInString(line)+1+utf8.RuneCountInString(item) <= width: + line += " " + item + default: + s.row(label, line) + + label, line = "", item + } + } + + if line != "" { + s.row(label, line) + } +} + +// pairs adds one row for each pair, with the label on the first row only and +// the reasons aligned. +func (s *screen) pairs(label string, items [][2]string) { + names := make([]string, len(items)) + width := 0 + + for n, item := range items { + names[n] = plugins.SafeText(item[0]) + width = max(width, utf8.RuneCountInString(names[n])) + } + + for n, item := range items { + if n > 0 { + label = "" + } + + s.row(label, fmt.Sprintf("%-*s %s", width, names[n], item[1])) + } +} + +// scopeGroups holds scopes in the words the screen uses. +type scopeGroups struct { + // The resources that can be read, such as "tenant settings". + read []string + + // The resources that can be changed with the verbs allowed on each, such as + // "clients (create, update, delete)". + change []string + + // The scopes that are not of the form verb:resource, as they are. + other []string +} + +// changeVerbs are the verbs that the screen lists first, in this order. +var changeVerbs = []string{"create", "update", "delete"} + +// groupScopes sorts scopes of the form verb:resource into what can be read and +// what can be changed, and keeps the rest as they are. A scope with no colon, or +// with an empty verb or resource, does not parse. +func groupScopes(scopes []string) scopeGroups { + var groups scopeGroups + + reads := map[string]bool{} + changes := map[string]map[string]bool{} + + for _, scope := range scopes { + verb, resource, found := strings.Cut(scope, ":") + + switch { + case !found || verb == "" || resource == "": + groups.other = append(groups.other, scope) + case verb == "read": + reads[resource] = true + default: + if changes[resource] == nil { + changes[resource] = map[string]bool{} + } + + changes[resource][verb] = true + } + } + + for _, resource := range slices.Sorted(maps.Keys(reads)) { + groups.read = append(groups.read, resourceText(resource)) + } + + for _, resource := range slices.Sorted(maps.Keys(changes)) { + groups.change = append(groups.change, resourceText(resource)+" ("+strings.Join(orderVerbs(changes[resource]), ", ")+")") + } + + slices.Sort(groups.other) + groups.other = slices.Compact(groups.other) + + return groups +} + +// resourceText returns the resource of a scope in words. +func resourceText(resource string) string { + return strings.ReplaceAll(resource, "_", " ") +} + +// orderVerbs returns the verbs as create, update and delete when they are there, +// followed by the others in alphabetical order. +func orderVerbs(verbs map[string]bool) []string { + var ordered []string + + for _, verb := range changeVerbs { + if verbs[verb] { + ordered = append(ordered, verb) + } + } + + for _, verb := range slices.Sorted(maps.Keys(verbs)) { + if !slices.Contains(changeVerbs, verb) { + ordered = append(ordered, verb) + } + } + + return ordered +} + +// scopeRows adds a row for each group of scopes that has something in it. +func (s *screen) scopeRows(groups scopeGroups) { + if len(groups.read) > 0 { + s.row("Can read", strings.Join(groups.read, ", ")) + } + + if len(groups.change) > 0 { + s.row("Can change", strings.Join(groups.change, ", ")) + } + + if len(groups.other) > 0 { + s.row("Other", strings.Join(groups.other, ", ")) + } +} + +// render returns the screen for the request, which is the screen of an update +// when the plugin is installed already and the first screen otherwise. +func render(tenant string, req Request) string { + if req.Old != nil { + return renderChange(tenant, req) + } + + return renderFirst(tenant, req) +} + +// renderExactScopes returns the block that shows the scopes as they are, for the +// person who wants to see more than the groups of the screen: all the scopes on a +// first screen, and the scopes that were added and removed on the screen of an +// update. It is set off from the lines around it by a blank line before and after, +// and it is empty when there is no scope to show. +func renderExactScopes(req Request) string { + s := &screen{} + current := plugins.NormalizePermissions(req.New) + + if req.Old == nil { + s.wrapped("Exact scopes", current.Scopes) + } else { + added, removed, _ := diffBy(plugins.NormalizePermissions(*req.Old).Scopes, current.Scopes, func(scope string) string { return scope }) + + if len(added) > 0 { + s.wrapped("Added", added) + } + + if len(removed) > 0 { + s.wrapped("Removed", removed) + } + } + + if s.rows == 0 { + return "" + } + + return "\n" + s.text.String() + "\n" +} + +// renderFirst returns the screen for permissions that were not approved on the +// tenant before. +func renderFirst(tenant string, req Request) string { + s := &screen{} + permissions := plugins.NormalizePermissions(req.New) + + fmt.Fprintf(&s.text, "This plugin asks to use your Auth0 tenant %s:\n\n", plugins.SafeText(tenant)) + + if len(permissions.Scopes) == 0 { + s.row("Auth0 API", "none") + } else { + s.scopeRows(groupScopes(permissions.Scopes)) + } + + s.pairs("Hosts", pairsOf(permissions.Hosts, hostPair)) + s.pairs("Files", pairsOf(permissions.Files, filePair)) + s.pairs("Environment", pairsOf(permissions.Env, envPair)) + + interactive := "no" + if slices.ContainsFunc(req.NewCommands, func(c plugins.ConsentCommand) bool { return c.Interactive }) { + interactive = "yes" + } + + s.row("Interactive", interactive) + + s.text.WriteString("\n" + notEnforced + "\n\n") + + return s.text.String() +} + +// renderChange returns the screen for an update: only what was added, removed +// or changed compared with the permissions of the installed version. +func renderChange(tenant string, req Request) string { + s := &screen{} + + fmt.Fprintf(&s.text, "%s %s asks for different permissions on tenant %s\n", plugins.SafeText(req.ID), plugins.SafeText(req.Version), plugins.SafeText(tenant)) + + s.changes(req) + + s.text.WriteString(notEnforced + "\n\n") + + return s.text.String() +} + +// showsChange reports whether the screen of an update has anything to show. +func showsChange(req Request) bool { + s := &screen{} + s.changes(req) + + return s.rows > 0 +} + +// changes adds a row for each permission and command that was added, removed or +// changed compared with the installed version. +func (s *screen) changes(req Request) { + old, current := plugins.NormalizePermissions(*req.Old), plugins.NormalizePermissions(req.New) + + scopesAdded, scopesRemoved, _ := diffBy(old.Scopes, current.Scopes, func(scope string) string { return scope }) + s.scopeChanges("Added", scopesAdded) + s.scopeChanges("Removed", scopesRemoved) + + changeRows(s, "host", old.Hosts, current.Hosts, func(h plugins.HostPermission) string { return h.Host }, hostPair) + changeRows(s, "file", old.Files, current.Files, func(f plugins.FilePermission) string { return f.Path + "\x00" + string(f.Access) }, filePair) + changeRows(s, "variable", old.Env, current.Env, func(e plugins.EnvPermission) string { return e.Name }, envPair) + commandRows(s, req.OldCommands, req.NewCommands) +} + +// scopeChanges adds the rows for scopes that were added or removed, which are +// the same groups as on the first screen. +func (s *screen) scopeChanges(label string, scopes []string) { + groups := groupScopes(scopes) + + if len(groups.read) > 0 { + s.row(label, "can read "+strings.Join(groups.read, ", ")) + } + + if len(groups.change) > 0 { + s.row(label, "can change "+strings.Join(groups.change, ", ")) + } + + if len(groups.other) > 0 { + s.row(label, "other scopes "+strings.Join(groups.other, ", ")) + } +} + +// changeRows adds a row for each item that was added, removed or changed. Two +// items are the same item when they have the same key, and the item changed +// when anything else about it differs. +func changeRows[T comparable](s *screen, kind string, old, current []T, key func(T) string, pair func(T) [2]string) { + added, removed, changed := diffBy(old, current, key) + + for _, group := range []struct { + label string + items []T + }{{"Added", added}, {"Removed", removed}, {"Changed", changed}} { + for _, item := range group.items { + text := pair(item) + + s.row(group.label, strings.TrimSpace(fmt.Sprintf("%s %s %s", kind, text[0], text[1]))) + } + } +} + +// commandRows adds a row for each command that was added or removed, or whose +// interactive value changed. +func commandRows(s *screen, old, current []plugins.ConsentCommand) { + added, removed, changed := diffBy(old, current, func(c plugins.ConsentCommand) string { return c.Name }) + + for _, command := range added { + s.row("Added", "command "+command.Name+" ("+interactiveText(command.Interactive)+")") + } + + for _, command := range removed { + s.row("Removed", "command "+command.Name) + } + + for _, command := range changed { + s.row("Changed", "command "+command.Name+" is now "+interactiveText(command.Interactive)) + } +} + +// diffBy compares two lists of items. An item that is only in current was added, +// one that is only in old was removed, and one that is in both under the same +// key but is not equal changed, in which case the new item is returned. +func diffBy[T comparable](old, current []T, key func(T) string) (added, removed, changed []T) { + before := make(map[string]T, len(old)) + for _, item := range old { + before[key(item)] = item + } + + after := make(map[string]bool, len(current)) + + for _, item := range current { + after[key(item)] = true + + previous, found := before[key(item)] + + switch { + case !found: + added = append(added, item) + case previous != item: + changed = append(changed, item) + } + } + + for _, item := range old { + if !after[key(item)] { + removed = append(removed, item) + } + } + + return added, removed, changed +} + +func hostPair(h plugins.HostPermission) [2]string { + return [2]string{h.Host, freeText(h.Reason)} +} + +func filePair(f plugins.FilePermission) [2]string { + return [2]string{fmt.Sprintf("%s (%s)", freeText(f.Path), f.Access), freeText(f.Reason)} +} + +func envPair(e plugins.EnvPermission) [2]string { + return [2]string{e.Name, freeText(e.Reason)} +} + +// pairsOf converts items to the pairs that screen.pairs shows. +func pairsOf[T any](items []T, pair func(T) [2]string) [][2]string { + pairs := make([][2]string, 0, len(items)) + for _, item := range items { + pairs = append(pairs, pair(item)) + } + + return pairs +} + +func interactiveText(interactive bool) string { + if interactive { + return "interactive" + } + + return "not interactive" +} + +// freeText cleans a value that is free text in a manifest, such as a reason or a +// file path, so that it takes a single short line. It collapses every run of +// white space to one space, because a long run could push the rows that follow +// off the screen, or end in text that looks like a row of its own, and it cuts +// the value after maxValueRunes runes. +func freeText(text string) string { + text = strings.Join(strings.Fields(plugins.SafeText(text)), " ") + + if utf8.RuneCountInString(text) <= maxValueRunes { + return text + } + + return string([]rune(text)[:maxValueRunes]) + "…" +} diff --git a/internal/plugins/approvals/render_test.go b/internal/plugins/approvals/render_test.go new file mode 100644 index 000000000..e760279d1 --- /dev/null +++ b/internal/plugins/approvals/render_test.go @@ -0,0 +1,668 @@ +package approvals_test + +import ( + "fmt" + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/approvals" +) + +const ( + notEnforced = "Each command runs with only the permissions it needs. Hosts and files are declared by the publisher and are not enforced by the CLI.\n\n" + firstFooter = "\n" + notEnforced + firstHeader = "\nThis plugin asks to use your Auth0 tenant acme.us.auth0.com:\n\n" +) + +func TestFirstApprovalScreen(t *testing.T) { + tests := []struct { + name string + request func(approvals.Request) approvals.Request // Nil keeps the request as it is. + want string + }{ + { + name: "everything the plugin asks for", + want: firstHeader + + " Can read clients, connections, tenant settings\n" + + " Can change clients (update), connections (update)\n" + + " Hosts api.github.com Check SDK versions\n" + + " Files $CWD (write) Save the report\n" + + " Environment GITHUB_TOKEN Call the GitHub API\n" + + " Interactive no\n" + firstFooter, + }, + { + name: "an interactive command is flagged", + request: func(r approvals.Request) approvals.Request { + r.New = plugins.Permissions{Scopes: permissions.Scopes[:3]} + r.NewCommands = []plugins.ConsentCommand{{Name: "a"}, {Name: "b", Interactive: true}} + + return r + }, + want: firstHeader + + " Can read clients, connections, tenant settings\n" + + " Interactive yes\n" + firstFooter, + }, + { + name: "several hosts line up their reasons", + request: func(r approvals.Request) approvals.Request { + r.New = plugins.Permissions{Hosts: []plugins.HostPermission{{Host: "a.io", Reason: "One"}, {Host: "longer.example.com", Reason: "Two"}}} + + return r + }, + want: firstHeader + + " Auth0 API none\n" + + " Hosts a.io One\n" + + " longer.example.com Two\n" + + " Interactive no\n" + firstFooter, + }, + { + name: "control characters from the manifest cannot rewrite the screen", + request: func(r approvals.Request) approvals.Request { + r.New = plugins.Permissions{Hosts: []plugins.HostPermission{{Host: "a.io", Reason: "\x1b[2Jok\r\n Interactive yes"}}} + + return r + }, + want: firstHeader + + " Auth0 API none\n" + + " Hosts a.io �[2Jok�� Interactive yes\n" + + " Interactive no\n" + firstFooter, + }, + { + name: "a long run of white space cannot push the rows off the screen or forge one", + request: func(r approvals.Request) approvals.Request { + r.New = plugins.Permissions{Hosts: []plugins.HostPermission{{Host: "a.io", Reason: strings.Repeat(" ", 8000) + "Scopes read:clients"}}} + + return r + }, + want: firstHeader + + " Auth0 API none\n" + + " Hosts a.io Scopes read:clients\n" + + " Interactive no\n" + firstFooter, + }, + { + name: "white space of any kind collapses to one space", + request: func(r approvals.Request) approvals.Request { + r.New = plugins.Permissions{Hosts: []plugins.HostPermission{{Host: "a.io", Reason: " Check \u00a0 SDK\u3000\u2003versions "}}} + + return r + }, + want: firstHeader + + " Auth0 API none\n" + + " Hosts a.io Check SDK versions\n" + + " Interactive no\n" + firstFooter, + }, + { + name: "a reason and a file path are cut after 200 runes, which are not bytes", + request: func(r approvals.Request) approvals.Request { + r.New = plugins.Permissions{ + Hosts: []plugins.HostPermission{{Host: "a.io", Reason: strings.Repeat("é", 201)}}, + Files: []plugins.FilePermission{{Path: "$CWD/" + strings.Repeat("y", 300), Access: plugins.FileAccessRead, Reason: strings.Repeat("é", 200)}}, + } + + return r + }, + want: firstHeader + + " Auth0 API none\n" + + " Hosts a.io " + strings.Repeat("é", 200) + "…\n" + + " Files $CWD/" + strings.Repeat("y", 195) + "… (read) " + strings.Repeat("é", 200) + "\n" + + " Interactive no\n" + firstFooter, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c, _, out := newConsenter(t, person, "n") + + req := request + if test.request != nil { + req = test.request(request) + } + + require.ErrorIs(t, c.Ask(t.Context(), req), approvals.ErrDeclined) + assert.Equal(t, test.want, out.String()) + }) + } +} + +// askScreens asks for the scopes with the answers, and returns what was written. +func askScreens(t *testing.T, scopes []string, lines ...string) (string, []string) { + t.Helper() + + c, prompter, out := newConsenter(t, person, lines...) + + req := request + req.New = plugins.Permissions{Scopes: scopes} + req.NewCommands = nil + + require.ErrorIs(t, c.Ask(t.Context(), req), approvals.ErrDeclined) + + return out.String(), prompter.prompts +} + +func TestScopeGrouping(t *testing.T) { + // A long list is shown in full, with no cut and no hint to press d. + var many, manyWant []string + + for n := range 12 { + many = append(many, fmt.Sprintf("read:resource_%02d", n)) + manyWant = append(manyWant, fmt.Sprintf("resource %02d", n)) + } + + tests := []struct { + name string + scopes []string + want string // The rows between the header and the Interactive row. + }{ + { + name: "read only", + scopes: []string{"read:tenant_settings", "read:clients"}, + want: " Can read clients, tenant settings\n", + }, + { + name: "change only", + scopes: []string{"update:clients", "create:clients", "delete:clients"}, + want: " Can change clients (create, update, delete)\n", + }, + { + name: "mixed", + scopes: []string{"read:clients", "read:tenant_settings", "create:clients", "update:clients", "delete:clients"}, + want: " Can read clients, tenant settings\n" + + " Can change clients (create, update, delete)\n", + }, + { + name: "verbs other than create, update and delete follow in alphabetical order", + scopes: []string{"deploy:actions", "create:actions", "block:actions", "delete:users", "update:users", "create:users", "revoke:users"}, + want: " Can change actions (create, block, deploy), users (create, update, delete, revoke)\n", + }, + { + name: "an underscore is shown as a space", + scopes: []string{"read:client_grants", "update:email_templates"}, + want: " Can read client grants\n" + + " Can change email templates (update)\n", + }, + { + name: "the same scope twice is shown once", + scopes: []string{"read:clients", "read:clients", "update:logs", "update:logs"}, + want: " Can read clients\n" + + " Can change logs (update)\n", + }, + { + name: "only the first colon splits a scope", + scopes: []string{"read:a:b"}, + want: " Can read a:b\n", + }, + { + name: "scopes that do not parse go to Other", + scopes: []string{"read:clients", "admin", ":clients", "read:", "update:"}, + want: " Can read clients\n" + + " Other :clients, admin, read:, update:\n", + }, + { + name: "no scopes", + scopes: nil, + want: " Auth0 API none\n", + }, + { + name: "a long list is not cut", + scopes: many, + want: " Can read " + strings.Join(manyWant, ", ") + "\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, _ := askScreens(t, test.scopes, "n") + + assert.Equal(t, firstHeader+test.want+" Interactive no\n"+firstFooter, got) + assert.NotContains(t, got, " Scopes ") + }) + } +} + +func TestExactScopesAreShownOnlyAfterD(t *testing.T) { + scopes := []string{"read:clients", "update:clients", "weird", "read:tenant_settings"} + block := "\n Exact scopes read:clients, read:tenant_settings, update:clients, weird\n\n" + + const ( + first = "Approve for acme.us.auth0.com? [y/N/d] (d shows the exact scopes) " + plain = "Approve for acme.us.auth0.com? [y/N] " + ) + + t.Run("the default screen has no exact scopes and offers d", func(t *testing.T) { + got, prompts := askScreens(t, scopes, "n") + + assert.NotContains(t, got, "Exact scopes") + assert.Equal(t, []string{first}, prompts) + }) + + t.Run("d prints only the block, without the screen again, and offers no d", func(t *testing.T) { + got, prompts := askScreens(t, scopes, "d", "n") + + assert.Equal(t, []string{first, plain}, prompts) + assert.Equal(t, 1, strings.Count(got, "This plugin asks to use your Auth0 tenant"), "the screen is not printed again") + assert.Equal(t, 1, strings.Count(got, notEnforced)) + + screen, _ := askScreens(t, scopes, "n") + assert.Equal(t, screen+block, got, "the block follows the screen and nothing else is written") + }) + + t.Run("a second d declines", func(t *testing.T) { + got, prompts := askScreens(t, scopes, "d", "d", "y") + + assert.Equal(t, []string{first, plain}, prompts) + assert.Equal(t, 1, strings.Count(got, "Exact scopes")) + }) + + t.Run("end of input after d declines", func(t *testing.T) { + got, prompts := askScreens(t, scopes, "d") + + assert.Equal(t, []string{first, plain}, prompts) + assert.Equal(t, 1, strings.Count(got, "Exact scopes")) + }) + + t.Run("no scopes, no d", func(t *testing.T) { + got, prompts := askScreens(t, nil, "d", "y") + + assert.Equal(t, []string{plain}, prompts, "d is not offered, and it declines as any answer but yes") + assert.Equal(t, 1, strings.Count(got, "This plugin asks to use your Auth0 tenant")) + assert.NotContains(t, got, "Exact scopes") + }) +} + +// exactBlock returns the lines of the block that d printed, without the blank +// lines around it. +func exactBlock(t *testing.T, got string) []string { + t.Helper() + + parts := strings.SplitAfter(got, notEnforced) + require.Len(t, parts, 2) + require.True(t, strings.HasPrefix(parts[1], "\n") && strings.HasSuffix(parts[1], "\n\n"), "a blank line before and after: %q", parts[1]) + + return strings.Split(strings.TrimSuffix(strings.TrimPrefix(parts[1], "\n"), "\n\n"), "\n") +} + +func TestExactScopesWrap(t *testing.T) { + const valueColumn = 2 + 12 + 1 + + indent := strings.Repeat(" ", valueColumn) + + // Check asserts that the lines hold the scopes in order, and that the lines wrap + // as described. A scope that does not fit in a line is alone on its line. + check := func(t *testing.T, lines []string, label string, want []string) { + t.Helper() + + var got []string + + for n, line := range lines { + if n == 0 { + require.True(t, strings.HasPrefix(line, " "+label+" "), line) + } else { + require.True(t, strings.HasPrefix(line, indent) && line[valueColumn] != ' ', "continuation indent: %q", line) + } + + fields := strings.Fields(line) + if n == 0 { + fields = fields[len(strings.Fields(label)):] + } + + if utf8.RuneCountInString(line) > 80 { + assert.Len(t, fields, 1, "only a scope that is wider than a line is over 80 columns: %q", line) + } + + for _, field := range fields { + got = append(got, strings.TrimSuffix(field, ",")) + } + + assert.Equal(t, n < len(lines)-1, strings.HasSuffix(line, ","), "a comma ends every line but the last: %q", line) + } + + assert.Equal(t, want, got) + } + + t.Run("a long list wraps between scopes", func(t *testing.T) { + scopes := make([]string, 0, 12) + for n := range 12 { + scopes = append(scopes, fmt.Sprintf("read:resource_%02d", n)) + } + + got, _ := askScreens(t, scopes, "d", "n") + lines := exactBlock(t, got) + + assert.Greater(t, len(lines), 1) + check(t, lines, "Exact scopes", scopes) + assert.Equal(t, " Exact scopes read:resource_00, read:resource_01, read:resource_02,", lines[0]) + }) + + t.Run("a line takes 80 columns and no more", func(t *testing.T) { + // The value has 65 columns. A scope of 31 columns with its comma takes 32, so two + // of them and the space between fill a line to its 80th column, and a third + // scope, or a second one that is a column longer, starts the next line. + fits := []string{"a:" + strings.Repeat("x", 29), "b:" + strings.Repeat("x", 29), "c:y"} + + got, _ := askScreens(t, fits, "d", "n") + lines := exactBlock(t, got) + + require.Len(t, lines, 2) + assert.Equal(t, 80, utf8.RuneCountInString(lines[0])) + check(t, lines, "Exact scopes", fits) + + over := []string{"a:" + strings.Repeat("x", 29), "b:" + strings.Repeat("x", 30), "c:y"} + + got, _ = askScreens(t, over, "d", "n") + lines = exactBlock(t, got) + + assert.Equal(t, []string{" Exact scopes " + over[0] + ",", indent + over[1] + ", " + over[2]}, lines) + check(t, lines, "Exact scopes", over) + }) + + t.Run("a scope that is wider than a line is not split", func(t *testing.T) { + long := "read:" + strings.Repeat("x", 90) + scopes := []string{"a:b", long, "z:y"} + + got, _ := askScreens(t, scopes, "d", "n") + lines := exactBlock(t, got) + + assert.Equal(t, []string{" Exact scopes a:b,", indent + long + ",", indent + "z:y"}, lines) + }) + + t.Run("the rows of an update wrap in the same way", func(t *testing.T) { + added := make([]string, 0, 10) + for n := range 10 { + added = append(added, fmt.Sprintf("create:resource_%02d", n)) + } + + before := plugins.Permissions{Scopes: []string{"read:clients"}} + change := approvals.Request{ID: id, Version: "1.1.0", Old: &before, New: plugins.Permissions{Scopes: append([]string{"read:clients"}, added...)}} + + c, _, out := newConsenter(t, person, "d", "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(before), nil))) + require.ErrorIs(t, c.Ask(t.Context(), change), approvals.ErrDeclined) + + lines := exactBlock(t, out.String()) + + assert.Greater(t, len(lines), 1) + check(t, lines, "Added", added) + }) +} + +func TestUpdateScreen(t *testing.T) { + old := plugins.Permissions{ + Scopes: []string{"read:clients", "read:logs"}, + Hosts: []plugins.HostPermission{{Host: "api.github.com", Reason: "Check SDK versions"}, {Host: "old.example.com", Reason: "Old"}}, + Files: []plugins.FilePermission{{Path: "$CWD", Access: plugins.FileAccessWrite, Reason: "Save it"}}, + Env: []plugins.EnvPermission{{Name: "GITHUB_TOKEN", Reason: "Call the GitHub API"}}, + } + oldCommands := []plugins.ConsentCommand{{Name: "checkmate"}, {Name: "gone"}, {Name: "toggle"}} + oldHash := plugins.ConsentHash(plugins.NormalizePermissions(old), oldCommands) + + updated := approvals.Request{ + ID: id, + Version: "1.1.0", + Old: &old, + New: plugins.Permissions{ + Scopes: []string{"read:clients", "read:connections"}, + Hosts: []plugins.HostPermission{{Host: "api.github.com", Reason: "Check SDK versions"}, {Host: "new.example.com", Reason: "New"}}, + Files: []plugins.FilePermission{{Path: "$CWD", Access: plugins.FileAccessWrite, Reason: "Save the report"}}, + Env: []plugins.EnvPermission{{Name: "GITHUB_TOKEN", Reason: "A new reason"}}, + }, + OldCommands: oldCommands, + NewCommands: []plugins.ConsentCommand{{Name: "checkmate"}, {Name: "fresh", Interactive: true}, {Name: "toggle", Interactive: true}}, + } + + t.Run("shows only what was added, removed or changed", func(t *testing.T) { + c, _, out := newConsenter(t, person, "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, oldHash)) + + require.ErrorIs(t, c.Ask(t.Context(), updated), approvals.ErrDeclined) + assert.Equal(t, "\nauth0/checkmate 1.1.0 asks for different permissions on tenant acme.us.auth0.com\n"+ + " Added can read connections\n"+ + " Removed can read logs\n"+ + " Added host new.example.com New\n"+ + " Removed host old.example.com Old\n"+ + " Changed file $CWD (write) Save the report\n"+ + " Changed variable GITHUB_TOKEN A new reason\n"+ + " Added command fresh (interactive)\n"+ + " Removed command gone\n"+ + " Changed command toggle is now interactive\n"+ + notEnforced, out.String()) + }) + + t.Run("scopes use the same groups as the first screen", func(t *testing.T) { + before := plugins.Permissions{Scopes: []string{"read:clients", "read:logs", "update:clients", "delete:clients"}} + after := plugins.Permissions{Scopes: []string{"read:clients", "read:tenant_settings", "update:clients", "create:clients", "deploy:actions", "odd"}} + change := approvals.Request{ID: id, Version: "1.1.0", Old: &before, New: after} + + const header = "\nauth0/checkmate 1.1.0 asks for different permissions on tenant acme.us.auth0.com\n" + + t.Run("groups", func(t *testing.T) { + c, _, out := newConsenter(t, person, "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(before), nil))) + + require.ErrorIs(t, c.Ask(t.Context(), change), approvals.ErrDeclined) + assert.Equal(t, header+ + " Added can read tenant settings\n"+ + " Added can change actions (deploy), clients (create)\n"+ + " Added other scopes odd\n"+ + " Removed can read logs\n"+ + " Removed can change clients (delete)\n"+ + notEnforced, out.String()) + }) + + t.Run("d prints the scopes that were added and removed as they are", func(t *testing.T) { + c, prompter, out := newConsenter(t, person, "d", "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(before), nil))) + + require.ErrorIs(t, c.Ask(t.Context(), change), approvals.ErrDeclined) + + assert.Equal(t, []string{ + "Approve for acme.us.auth0.com? [y/N/d] (d shows the exact scopes) ", + "Approve for acme.us.auth0.com? [y/N] ", + }, prompter.prompts) + + parts := strings.SplitAfter(out.String(), notEnforced) + require.Len(t, parts, 2, "the screen is printed once") + assert.Equal(t, "\n"+ + " Added create:clients, deploy:actions, odd, read:tenant_settings\n"+ + " Removed delete:clients, read:logs\n"+ + "\n", parts[1]) + assert.NotContains(t, out.String(), "Exact scopes") + }) + + t.Run("d leaves out the row that is empty", func(t *testing.T) { + only := approvals.Request{ID: id, Version: "1.1.0", Old: &before, New: plugins.Permissions{Scopes: append([]string{"read:billing"}, before.Scopes...)}} + + c, _, out := newConsenter(t, person, "d", "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(before), nil))) + + require.ErrorIs(t, c.Ask(t.Context(), only), approvals.ErrDeclined) + assert.Equal(t, "\n Added read:billing\n\n", strings.SplitAfter(out.String(), notEnforced)[1]) + }) + + t.Run("an update with no difference in scopes offers no d", func(t *testing.T) { + hosts := approvals.Request{ID: id, Version: "1.1.0", Old: &before, New: plugins.Permissions{Scopes: before.Scopes, Hosts: []plugins.HostPermission{{Host: "a.io", Reason: "One"}}}} + + c, prompter, out := newConsenter(t, person, "d", "y") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(before), nil))) + + require.ErrorIs(t, c.Ask(t.Context(), hosts), approvals.ErrDeclined) + assert.Equal(t, []string{"Approve for acme.us.auth0.com? [y/N] "}, prompter.prompts) + assert.Contains(t, out.String(), "asks for different permissions") + assert.NotContains(t, out.String(), "Added read") + }) + }) + + t.Run("an update with the same permissions shows the full screen", func(t *testing.T) { + same := updated + same.New, same.NewCommands = old, oldCommands + + c, _, out := newConsenter(t, person, "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, oldHash)) + + require.ErrorIs(t, c.Ask(t.Context(), same), approvals.ErrDeclined) + assert.Contains(t, out.String(), "This plugin asks to use your Auth0 tenant acme.us.auth0.com") + assert.NotContains(t, out.String(), "asks for different permissions") + assert.NotContains(t, out.String(), "no difference") + }) + + t.Run("a removed access to a file is shown", func(t *testing.T) { + both := plugins.Permissions{Files: []plugins.FilePermission{ + {Path: "$CWD", Access: plugins.FileAccessRead, Reason: "Read it"}, + {Path: "$CWD", Access: plugins.FileAccessWrite, Reason: "Save it"}, + }} + fewer := approvals.Request{ID: id, Version: "1.1.0", Old: &both, New: plugins.Permissions{Files: both.Files[1:]}} + + c, _, out := newConsenter(t, person, "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(both), nil))) + + require.ErrorIs(t, c.Ask(t.Context(), fewer), approvals.ErrDeclined) + assert.Contains(t, out.String(), " Removed file $CWD (read) Read it\n") + assert.NotContains(t, out.String(), "no difference") + }) + + t.Run("a difference that shows no item is never reported as no difference", func(t *testing.T) { + // Two entries for one host with other reasons count as one when they are + // compared by host, so dropping one of them changes the hash and not the rows. + twice := plugins.Permissions{Hosts: []plugins.HostPermission{{Host: "a.io", Reason: "One"}, {Host: "a.io", Reason: "Two"}}} + once := plugins.Permissions{Hosts: twice.Hosts[1:]} + fewer := approvals.Request{ID: id, Version: "1.1.0", Old: &twice, New: once} + + c, _, out := newConsenter(t, person, "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(twice), nil))) + + require.ErrorIs(t, c.Ask(t.Context(), fewer), approvals.ErrDeclined) + assert.Contains(t, out.String(), "This plugin asks to use your Auth0 tenant acme.us.auth0.com") + assert.NotContains(t, out.String(), "no difference") + }) + + // The installed version is whatever is on this machine, which says nothing about + // what the tenant approved. The difference is only shown to a tenant that + // approved the installed version, and everything is shown to any other. + tests := []struct { + name string + setup func(*testing.T, *approvals.Consenter) + wantDiff bool + wantError error + }{ + {name: "a tenant with no record sees everything", setup: func(*testing.T, *approvals.Consenter) {}}, + {name: "a tenant that approved another version sees everything", setup: func(t *testing.T, c *approvals.Consenter) { + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, hashA)) + }}, + {name: "a tenant that approved the installed version sees the difference", wantDiff: true, setup: func(t *testing.T, c *approvals.Consenter) { + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, oldHash)) + }}, + {name: "a pin of the installed version shows the difference", wantDiff: true, setup: func(_ *testing.T, c *approvals.Consenter) { + c.Approvals.Pins = approvals.Pins{id: {oldHash}} + }}, + {name: "a pin of another version sees everything", setup: func(_ *testing.T, c *approvals.Consenter) { + c.Approvals.Pins = approvals.Pins{id: {hashA}} + }}, + {name: "a damaged file stops the update", wantError: approvals.ErrDamaged, setup: func(t *testing.T, c *approvals.Consenter) { + writeFile(t, c.Approvals, "not json") + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c, _, out := newConsenter(t, person, "n") + test.setup(t, c) + + err := c.Ask(t.Context(), updated) + + if test.wantError != nil { + require.ErrorIs(t, err, test.wantError) + assert.Zero(t, out.Len()) + + return + } + + require.ErrorIs(t, err, approvals.ErrDeclined) + + // The host and the scope exist in the old and the new version, so only the + // full screen lists them. + if test.wantDiff { + assert.Contains(t, out.String(), "asks for different permissions") + assert.NotContains(t, out.String(), "api.github.com") + assert.NotContains(t, out.String(), "This plugin asks to use") + + return + } + + assert.Contains(t, out.String(), "This plugin asks to use your Auth0 tenant acme.us.auth0.com") + assert.Contains(t, out.String(), "api.github.com") + assert.Contains(t, out.String(), " Can read clients, connections\n") + assert.NotContains(t, out.String(), "asks for different permissions") + }) + } +} + +func TestInvisibleCharactersAreReplaced(t *testing.T) { + const ( + bidi = "\u202e" + isolate = "\u2066" + zeroWidth = "\u200b" + kept = "für 日本語" + ) + + files := []plugins.FilePermission{{Path: "$CWD/evil" + bidi + "txt.exe", Access: plugins.FileAccessWrite, Reason: "Save" + isolate + " the" + zeroWidth + " report " + kept}} + first := request + first.New = plugins.Permissions{Files: files} + + old := plugins.Permissions{Scopes: []string{"read:clients"}} + update := approvals.Request{ID: id, Version: "1.1.0", Old: &old, New: first.New} + + for header, req := range map[string]approvals.Request{"This plugin asks to use": first, "asks for different permissions": update} { + t.Run(header, func(t *testing.T) { + c, _, out := newConsenter(t, person, "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(old), nil))) + + require.ErrorIs(t, c.Ask(t.Context(), req), approvals.ErrDeclined) + require.Contains(t, out.String(), header) + + for _, character := range []string{bidi, isolate, zeroWidth} { + assert.NotContains(t, out.String(), character) + } + + assert.Contains(t, out.String(), "$CWD/evil\ufffdtxt.exe (write)") + assert.Contains(t, out.String(), "Save\ufffd the\ufffd report "+kept) + }) + } +} + +func TestExactScopesAreCleaned(t *testing.T) { + const ( + bidi = "\u202e" + zeroWidth = "\u200b" + ) + + scopes := []string{"read:cli" + bidi + "ents", "update:" + zeroWidth + "logs", "odd\x1b[2J\r\nInteractive yes"} + + before := plugins.Permissions{Scopes: []string{"read:clients"}} + update := approvals.Request{ID: id, Version: "1.1.0", Old: &before, New: plugins.Permissions{Scopes: scopes}} + + first := request + first.New = plugins.Permissions{Scopes: scopes} + + for name, req := range map[string]approvals.Request{"first screen": first, "update": update} { + t.Run(name, func(t *testing.T) { + c, _, out := newConsenter(t, person, "d", "n") + require.NoError(t, c.Approvals.Approve(t.Context(), tenant, id, plugins.ConsentHash(plugins.NormalizePermissions(before), nil))) + + require.ErrorIs(t, c.Ask(t.Context(), req), approvals.ErrDeclined) + + block := strings.SplitAfter(out.String(), notEnforced)[1] + + for _, character := range []string{bidi, zeroWidth, "\x1b", "\r"} { + assert.NotContains(t, block, character) + } + + assert.Equal(t, 1, strings.Count(block, "\n\n"), "no scope can forge a line or a blank line") + assert.Contains(t, block, "read:cli\ufffdents,") + assert.Contains(t, block, "update:\ufffdlogs\n") + }) + } +}