diff --git a/docs/auth0_universal-portals.md b/docs/auth0_universal-portals.md index ab905bfbd..315a49f01 100644 --- a/docs/auth0_universal-portals.md +++ b/docs/auth0_universal-portals.md @@ -5,9 +5,10 @@ has_children: true --- # auth0 universal-portals -Manage Auth0 Universal Portals resources. +Set up and audit Auth0 Universal Portals. ## Commands -- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application +- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal diff --git a/docs/auth0_universal-portals_audit.md b/docs/auth0_universal-portals_audit.md new file mode 100644 index 000000000..7be40a5d7 --- /dev/null +++ b/docs/auth0_universal-portals_audit.md @@ -0,0 +1,54 @@ +--- +layout: default +parent: auth0 universal-portals +has_toc: false +--- +# auth0 universal-portals audit + +Checks a Universal Portal and its application against what setup makes, and tells what is wrong and how to fix it. + +An error breaks the portal or one of its components, and a warning is something degraded or different from the setup guide. The command fails when there is an error, and with --strict when there is a warning too. + +With a terminal it offers to fix the errors the CLI can fix, and with --fix it fixes them without asking. Warnings are never changed, because they may be intentional. + +## Usage +``` +auth0 universal-portals audit [flags] +``` + +## Examples + +``` + auth0 universal-portals audit up_123 + auth0 universal-portals audit up_123 --fix + auth0 up audit up_123 --organization org_123 --strict --json +``` + + +## Flags + +``` + --fix Apply the errors the CLI can fix, without asking. Warnings are never changed. + --json Output in json format. + -o, --organization string Business only: the ID of the organization the portal opens in, to check that it shares a connection with the application. + --strict Also fail when there are warnings. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal + + diff --git a/docs/auth0_universal-portals_setup.md b/docs/auth0_universal-portals_setup.md index db2f0bf13..7afb7246d 100644 --- a/docs/auth0_universal-portals_setup.md +++ b/docs/auth0_universal-portals_setup.md @@ -5,11 +5,9 @@ has_toc: false --- # auth0 universal-portals setup -Provisions the Auth0 resources required by a Universal Portals application: +Creates a Universal Portal with its application, forms and pages, and checks that the logged-in session has the permissions it needs before it creates anything. - - Auth0 My Account API and My Organization API (resource servers) - - A Regular Web App client with the required configuration - - Client grants for My Account API, My Organization API, and the Management API +Running the command again after a failure resumes: what exists is reused. ## Usage ``` @@ -20,16 +18,22 @@ auth0 universal-portals setup [flags] ``` auth0 universal-portals setup - auth0 universal-portals setup --name "Acme" --slug "acme" - auth0 up setup -n "Acme" -s "acme" + auth0 universal-portals setup --template business --name "Acme Admin" --user jane@acme.com + auth0 universal-portals setup --template consumer --name "Acme Account" --dry-run + auth0 up setup --no-input --template consumer --name "Acme Account" --json ``` ## Flags ``` - -n, --name string Display name of the portal. - -s, --slug string URL-friendly identifier for the portal (e.g. my-portal). + --dry-run Show what setup would create, without changing the tenant. + --json Output in json format. + -n, --name string Name of the portal. Asked when omitted. + -o, --organization string Business only: the ID or name of the organization, when the user has several. + -s, --slug string URL slug of the portal, for example acme-account. Defaults to a slug made from the name. + --template string Kind of portal: consumer, for end-users to manage their account, or business, for organization administrators to manage their organization. Asked when omitted. + -u, --user string Business only: the email or user ID of the user who will test the portal. ``` @@ -46,6 +50,7 @@ auth0 universal-portals setup [flags] ## Related Commands -- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up Auth0 resources for a Universal Portals application +- [auth0 universal-portals audit](auth0_universal-portals_audit.md) - Check a Universal Portal +- [auth0 universal-portals setup](auth0_universal-portals_setup.md) - Set up a Universal Portal diff --git a/docs/index.md b/docs/index.md index 6b931069d..c5e1388ca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -121,6 +121,6 @@ The help for any command can also be emitted as JSON by combining `--help` with - [auth0 test](auth0_test.md) - Try your Universal Login box or get a token - [auth0 token-exchange](auth0_token-exchange.md) - Manage token exchange profiles - [auth0 universal-login](auth0_universal-login.md) - Manage the Universal Login experience -- [auth0 universal-portals](auth0_universal-portals.md) - Manage Universal Portals resources +- [auth0 universal-portals](auth0_universal-portals.md) - Set up and audit Universal Portals - [auth0 users](auth0_users.md) - Manage resources for users diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index c13097c35..8450df556 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -99,6 +99,7 @@ type APIV3 struct { NetworkACLKey NetworkACLKeyAPIV3 Connection ConnectionAPIV3 ConnectionEnabledClient ConnectionEnabledClientAPIV3 + ClientConnection ClientConnectionAPIV3 Experiments ExperimentsAPI FeatureFlags FeatureFlagsAPI Variations VariationsAPI @@ -133,6 +134,7 @@ func NewAPIV3(m *managementv3.Management) *APIV3 { NetworkACLKey: m.Keys.NetworkACLs, Connection: m.Connections, ConnectionEnabledClient: m.Connections.Clients, + ClientConnection: m.Clients.Connections, Experiments: m.Experimentation.Experiments, FeatureFlags: m.Experimentation.FeatureFlags, Variations: m.Experimentation.FeatureFlags.Variations, diff --git a/internal/auth0/client_connection_v3.go b/internal/auth0/client_connection_v3.go new file mode 100644 index 000000000..04b6ee4a0 --- /dev/null +++ b/internal/auth0/client_connection_v3.go @@ -0,0 +1,30 @@ +//go:generate go tool mockgen -source=client_connection_v3.go -destination=mock/client_connection_v3_mock.go -package=mock + +package auth0 + +import ( + "context" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/go-auth0/v3/management/option" +) + +// ClientConnectionPage aliases the checkpoint-paginated list of the connections +// enabled for a client. The alias keeps the interface return type a single +// identifier so mockgen's source parser can handle it. +type ClientConnectionPage = core.Page[*string, *managementv3.ConnectionForList, *managementv3.ListClientConnectionsResponseContent] + +// ClientConnectionAPIV3 is the V3 SDK interface for the /clients/{id}/connections +// endpoint, used to read which connections are enabled for a client. +type ClientConnectionAPIV3 interface { + // Get the connections enabled for a client. The request must not be nil. + // + // Required scope: `read:connections`. + Get( + ctx context.Context, + id string, + request *managementv3.ConnectionsGetRequest, + opts ...option.RequestOption, + ) (*ClientConnectionPage, error) +} diff --git a/internal/auth0/mock/client_connection_v3_mock.go b/internal/auth0/mock/client_connection_v3_mock.go new file mode 100644 index 000000000..00ca24209 --- /dev/null +++ b/internal/auth0/mock/client_connection_v3_mock.go @@ -0,0 +1,64 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client_connection_v3.go +// +// Generated by this command: +// +// mockgen -source=client_connection_v3.go -destination=mock/client_connection_v3_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + auth0 "github.com/auth0/auth0-cli/internal/auth0" + management "github.com/auth0/go-auth0/v3/management" + option "github.com/auth0/go-auth0/v3/management/option" + gomock "go.uber.org/mock/gomock" +) + +// MockClientConnectionAPIV3 is a mock of ClientConnectionAPIV3 interface. +type MockClientConnectionAPIV3 struct { + ctrl *gomock.Controller + recorder *MockClientConnectionAPIV3MockRecorder + isgomock struct{} +} + +// MockClientConnectionAPIV3MockRecorder is the mock recorder for MockClientConnectionAPIV3. +type MockClientConnectionAPIV3MockRecorder struct { + mock *MockClientConnectionAPIV3 +} + +// NewMockClientConnectionAPIV3 creates a new mock instance. +func NewMockClientConnectionAPIV3(ctrl *gomock.Controller) *MockClientConnectionAPIV3 { + mock := &MockClientConnectionAPIV3{ctrl: ctrl} + mock.recorder = &MockClientConnectionAPIV3MockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClientConnectionAPIV3) EXPECT() *MockClientConnectionAPIV3MockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockClientConnectionAPIV3) Get(ctx context.Context, id string, request *management.ConnectionsGetRequest, opts ...option.RequestOption) (*auth0.ClientConnectionPage, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, id, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*auth0.ClientConnectionPage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockClientConnectionAPIV3MockRecorder) Get(ctx, id, request any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClientConnectionAPIV3)(nil).Get), varargs...) +} diff --git a/internal/auth0/mock/organization_mock.go b/internal/auth0/mock/organization_mock.go index 54cb78345..ad6142ef5 100644 --- a/internal/auth0/mock/organization_mock.go +++ b/internal/auth0/mock/organization_mock.go @@ -41,6 +41,63 @@ func (m *MockOrganizationAPI) EXPECT() *MockOrganizationAPIMockRecorder { return m.recorder } +// AddConnection mocks base method. +func (m *MockOrganizationAPI) AddConnection(ctx context.Context, id string, c *management.OrganizationConnection, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, id, c} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AddConnection", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddConnection indicates an expected call of AddConnection. +func (mr *MockOrganizationAPIMockRecorder) AddConnection(ctx, id, c any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, id, c}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddConnection", reflect.TypeOf((*MockOrganizationAPI)(nil).AddConnection), varargs...) +} + +// AddMembers mocks base method. +func (m *MockOrganizationAPI) AddMembers(ctx context.Context, id string, memberIDs []string, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, id, memberIDs} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AddMembers", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddMembers indicates an expected call of AddMembers. +func (mr *MockOrganizationAPIMockRecorder) AddMembers(ctx, id, memberIDs any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, id, memberIDs}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMembers", reflect.TypeOf((*MockOrganizationAPI)(nil).AddMembers), varargs...) +} + +// AssignMemberRoles mocks base method. +func (m *MockOrganizationAPI) AssignMemberRoles(ctx context.Context, id, memberID string, roles []string, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, id, memberID, roles} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AssignMemberRoles", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// AssignMemberRoles indicates an expected call of AssignMemberRoles. +func (mr *MockOrganizationAPIMockRecorder) AssignMemberRoles(ctx, id, memberID, roles any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, id, memberID, roles}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssignMemberRoles", reflect.TypeOf((*MockOrganizationAPI)(nil).AssignMemberRoles), varargs...) +} + // Connections mocks base method. func (m *MockOrganizationAPI) Connections(ctx context.Context, id string, opts ...management.RequestOption) (*management.OrganizationConnectionList, error) { m.ctrl.T.Helper() diff --git a/internal/auth0/mock/user_mock.go b/internal/auth0/mock/user_mock.go index e12df79c4..5c9fc8f54 100644 --- a/internal/auth0/mock/user_mock.go +++ b/internal/auth0/mock/user_mock.go @@ -181,6 +181,26 @@ func (mr *MockUserAPIMockRecorder) ListByEmail(ctx, email any, opts ...any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByEmail", reflect.TypeOf((*MockUserAPI)(nil).ListByEmail), varargs...) } +// Organizations mocks base method. +func (m *MockUserAPI) Organizations(ctx context.Context, id string, opts ...management.RequestOption) (*management.OrganizationList, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Organizations", varargs...) + ret0, _ := ret[0].(*management.OrganizationList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Organizations indicates an expected call of Organizations. +func (mr *MockUserAPIMockRecorder) Organizations(ctx, id any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, id}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Organizations", reflect.TypeOf((*MockUserAPI)(nil).Organizations), varargs...) +} + // Read mocks base method. func (m *MockUserAPI) Read(ctx context.Context, id string, opts ...management.RequestOption) (*management.User, error) { m.ctrl.T.Helper() diff --git a/internal/auth0/organization.go b/internal/auth0/organization.go index e339bee09..4075fdc29 100644 --- a/internal/auth0/organization.go +++ b/internal/auth0/organization.go @@ -49,6 +49,21 @@ type OrganizationAPI interface { // See: https://auth0.com/docs/api/management/v2/#!/Organizations/get_enabled_connections Connections(ctx context.Context, id string, opts ...management.RequestOption) (c *management.OrganizationConnectionList, err error) + // AddConnection enables a connection for an organization. + // + // See: https://auth0.com/docs/api/management/v2#!/Organizations/post_enabled_connections + AddConnection(ctx context.Context, id string, c *management.OrganizationConnection, opts ...management.RequestOption) (err error) + + // AddMembers adds users to an organization as members. + // + // See: https://auth0.com/docs/api/management/v2#!/Organizations/post_members + AddMembers(ctx context.Context, id string, memberIDs []string, opts ...management.RequestOption) (err error) + + // AssignMemberRoles assigns roles to a member of an organization. + // + // See: https://auth0.com/docs/api/management/v2#!/Organizations/post_organization_member_roles + AssignMemberRoles(ctx context.Context, id string, memberID string, roles []string, opts ...management.RequestOption) (err error) + // DiscoveryDomains retrieves the discovery domains for an organization. DiscoveryDomains(ctx context.Context, id string, opts ...management.RequestOption) (d *management.DiscoveryDomainList, err error) diff --git a/internal/auth0/user.go b/internal/auth0/user.go index aefeb1cdf..c1f9844fa 100644 --- a/internal/auth0/user.go +++ b/internal/auth0/user.go @@ -56,6 +56,9 @@ type UserAPI interface { // Roles lists all roles associated with a user. Roles(ctx context.Context, id string, opts ...management.RequestOption) (r *management.RoleList, err error) + // Organizations lists the organizations a user is a member of. + Organizations(ctx context.Context, id string, opts ...management.RequestOption) (p *management.OrganizationList, err error) + // AssignRoles assigns roles to a user. AssignRoles(ctx context.Context, id string, roles []*management.Role, opts ...management.RequestOption) error diff --git a/internal/cli/auth_env.go b/internal/cli/auth_env.go index 1f8e1274e..60bae23a6 100644 --- a/internal/cli/auth_env.go +++ b/internal/cli/auth_env.go @@ -172,6 +172,7 @@ func (c *cli) setupWithEnvAuthentication(ctx context.Context) error { c.api = auth0.NewAPI(api) c.apiv3 = auth0.NewAPIV3(apiv3) + c.apiAccess = apiAccess{domain: domain, accessToken: accessToken, invokerMetadata: invokerMetadata} return nil } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index df4f43aa3..a683f7aa3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -58,9 +58,20 @@ type cli struct { detectedAgent string executedCommandPath string + // What the Management API clients were built with, for a command that builds a client of its own. + apiAccess apiAccess + Config config.Config } +// apiAccess is the tenant and the token the Management API clients were built with, and the metadata +// they send with each request. +type apiAccess struct { + domain string + accessToken string + invokerMetadata string +} + // setupWithAuthentication will fetch the tenant from the config.json // and regenerate its access token if needed. The access token will // then be used to configure an instance of the Auth0 Management SDK. @@ -221,6 +232,7 @@ func (c *cli) setupWithAuthentication(ctx context.Context) error { c.api = auth0.NewAPI(api) c.apiv3 = auth0.NewAPIV3(apiv3) + c.apiAccess = apiAccess{domain: tenant.Domain, accessToken: accessToken, invokerMetadata: invokerMetadata} return nil } diff --git a/internal/cli/management.go b/internal/cli/management.go index 8e73206b1..acfb0c8cd 100644 --- a/internal/cli/management.go +++ b/internal/cli/management.go @@ -20,19 +20,30 @@ import ( ) func initializeManagementClient(tenantDomain string, accessToken string, invokerMetadata string) (*management.Management, error) { + return initializeManagementClientWith(tenantDomain, accessToken, customClientWithRetries(invokerMetadata)) +} + +// initializeManagementClientWith builds the client over an HTTP client of the caller, which is how a +// command sees or changes the requests of the client. +func initializeManagementClientWith(tenantDomain string, accessToken string, httpClient *http.Client) (*management.Management, error) { client, err := management.New( tenantDomain, management.WithStaticToken(accessToken), management.WithUserAgent(fmt.Sprintf("%v/%v", userAgent, strings.TrimPrefix(buildinfo.Version, "v"))), management.WithAuth0ClientEnvEntry("Auth0-CLI", strings.TrimPrefix(buildinfo.Version, "v")), management.WithNoRetries(), - management.WithClient(customClientWithRetries(invokerMetadata)), + management.WithClient(httpClient), ) return client, err } func initializeManagementClientV3(tenantDomain string, accessToken string, invokerMetadata string) (*managementv3.Management, error) { + return initializeManagementClientV3With(tenantDomain, accessToken, customClientWithRetries(invokerMetadata)) +} + +// initializeManagementClientV3With is initializeManagementClientWith for the v3 client. +func initializeManagementClientV3With(tenantDomain string, accessToken string, httpClient *http.Client) (*managementv3.Management, error) { client, err := managementv3.New( tenantDomain, option.WithToken(accessToken), @@ -42,7 +53,7 @@ func initializeManagementClientV3(tenantDomain string, accessToken string, invok // Setting it to 1 to avoid retries from `go-auth0` since we have our own retry logic in the custom HTTP client. // TODO: confirm this assumption, or check if this needs to be excluded like terraform provider. option.WithMaxAttempts(1), - option.WithHTTPClient(customClientWithRetries(invokerMetadata)), + option.WithHTTPClient(httpClient), ) return client, err } diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go index e0aca407f..69a5b369c 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -2,1051 +2,339 @@ package cli import ( "context" - "encoding/json" "errors" "fmt" - "net/http" + "os" + "os/signal" "strings" + "syscall" - "github.com/auth0/go-auth0/management" - managementv3 "github.com/auth0/go-auth0/v3/management" "github.com/pkg/browser" "github.com/spf13/cobra" "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/portals" ) -// Scopes granted to the portal client per audience. var ( - myAccountAPIScopes = []string{ - "read:me:authentication_methods", - "delete:me:authentication_methods", - "update:me:authentication_methods", - "read:me:factors", - "create:me:authentication_methods", + upTemplate = Flag{ + Name: "Template", + LongForm: "template", + Help: "Kind of portal: consumer, for end-users to manage their account, or business, for organization administrators to manage their organization. Asked when omitted.", } - - myOrgAPIScopes = []string{ - "read:my_org:configuration", - "read:my_org:details", - "update:my_org:details", - } - - managementAPIScopes = []string{ - "read:branding", - "read:organizations_summary", - "read:organizations", - "update:users", - "update:users_app_metadata", - } - - portalGrantTypes = []string{ - "authorization_code", - "refresh_token", - "client_credentials", - "http://auth0.com/oauth/grant-type/mfa-oob", - "http://auth0.com/oauth/grant-type/mfa-otp", - "http://auth0.com/oauth/grant-type/mfa-recovery-code", - } -) - -var ( - upPortalName = Flag{ + upName = Flag{ Name: "Name", LongForm: "name", ShortForm: "n", - Help: "Display name of the portal.", + Help: "Name of the portal. Asked when omitted.", } - upPortalSlug = Flag{ + upSlug = Flag{ Name: "Slug", LongForm: "slug", ShortForm: "s", - Help: "URL-friendly identifier for the portal (e.g. my-portal).", + Help: "URL slug of the portal, for example acme-account. Defaults to a slug made from the name.", + } + upUser = Flag{ + Name: "User", + LongForm: "user", + ShortForm: "u", + Help: "Business only: the email or user ID of the user who will test the portal.", + } + upDryRun = Flag{ + Name: "Dry Run", + LongForm: "dry-run", + Help: "Show what setup would create, without changing the tenant.", + } + upOrganization = Flag{ + Name: "Organization", + LongForm: "organization", + ShortForm: "o", + Help: "Business only: the ID or name of the organization, when the user has several.", } ) -// universalPortalsCmd groups Universal Portals management commands. +// universalPortalsCmd groups the Universal Portals commands. func universalPortalsCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "universal-portals", Aliases: []string{"up"}, - Short: "Manage Universal Portals resources", - Long: "Manage Auth0 Universal Portals resources.", + Short: "Set up and audit Universal Portals", + Long: "Set up and audit Auth0 Universal Portals.", } cmd.SetUsageTemplate(namespaceUsageTemplate()) - cmd.AddCommand(universalPortalsSetupCmd(cli)) + cmd.AddCommand( + universalPortalsSetupCmd(cli), + universalPortalsAuditCmd(cli), + ) return cmd } -// universalPortalsSetupCmd provisions all Auth0 resources required by a Universal Portals application. -func universalPortalsSetupCmd(cli *cli) *cobra.Command { - var inputs struct { - PortalName string - Slug string - } - - cmd := &cobra.Command{ - Use: "setup", - Args: cobra.NoArgs, - Short: "Set up Auth0 resources for a Universal Portals application", - Long: `Provisions the Auth0 resources required by a Universal Portals application: +// universalPortalsSetupInputs are the flags of setup. +type universalPortalsSetupInputs struct { + Template string + Name string + Slug string + User string + Organization string + DryRun bool +} - - Auth0 My Account API and My Organization API (resource servers) - - A Regular Web App client with the required configuration - - Client grants for My Account API, My Organization API, and the Management API`, - Example: ` auth0 universal-portals setup - auth0 universal-portals setup --name "Acme" --slug "acme" - auth0 up setup -n "Acme" -s "acme"`, - RunE: func(cmd *cobra.Command, args []string) error { - return runUniversalPortalsSetup(cmd, cli, inputs.PortalName, inputs.Slug) - }, +// options turns the flags into the options of setup. Setup checks them. +func (i universalPortalsSetupInputs) options() portals.SetupOptions { + return portals.SetupOptions{ + Template: portals.Template(i.Template), + Name: i.Name, + Slug: i.Slug, + UserQuery: i.User, + Organization: i.Organization, } - - upPortalName.RegisterString(cmd, &inputs.PortalName, "") - upPortalSlug.RegisterString(cmd, &inputs.Slug, "") - - return cmd } -// runUniversalPortalsSetup orchestrates the provisioning flow. -// It owns all display logic; business functions only return (value, error). -func runUniversalPortalsSetup(cmd *cobra.Command, cli *cli, portalName, slug string) error { - if err := cli.setupWithAuthentication(cmd.Context()); err != nil { - return fmt.Errorf("authentication required: %w", err) +// missingForNoPrompt returns the flags a run that cannot ask has to be given. +func (i universalPortalsSetupInputs) missingForNoPrompt() []string { + var missing []string + + if i.Template == "" { + missing = append(missing, "--template") } - // Verify the token carries the scopes required by the EA setup endpoints. - // These are intentionally not in RequiredScopes to avoid breaking login for - // tenants without the feature flag. - // create:flows_vault_connections — POST /api/v2/flows/vault/connections - // create:forms, create:flows — POST /api/v2/forms/import - // create:portals — POST /api/v2/portals - // - // Scopes are read from the JWT directly so this works regardless of auth - // method (device code, client secret, etc.). - upRequiredScopes := []string{ - "create:flows_vault_connections", - "create:forms", - "create:flows", - "create:portals", + if i.Name == "" { + missing = append(missing, "--name") } - if tenant, err := cli.Config.GetTenant(cli.tenant); err == nil { - if granted := scopesFromToken(tenant.GetAccessToken()); granted != nil { - if missing := missingScopes(granted, upRequiredScopes); len(missing) > 0 { - // In --no-input mode we can't launch the interactive login, - // so surface the exact command the user needs to run. - if cli.noInput { - return fmt.Errorf( - "insufficient scopes to provision Universal Portals\nMissing: %s\nRe-authenticate to continue: auth0 login --scopes %s", - strings.Join(missing, ", "), - strings.Join(upRequiredScopes, ","), - ) - } - // RunLoginAsUser gates on a "Press Enter / ^C" prompt before - // opening the browser, so warn here instead of double-confirming. - cli.renderer.Warnf( - "Universal Portals needs additional scopes (missing: %s).\nYou'll be asked to log in again to grant them.\n", - strings.Join(missing, ", "), - ) + if i.Template == string(portals.TemplateBusiness) && i.User == "" { + missing = append(missing, "--user") + } - // Request the full scope set so one login grants everything. - if _, err := RunLoginAsUser(cmd.Context(), cli, upRequiredScopes, ""); err != nil { - return fmt.Errorf("failed to re-authenticate with the required scopes: %w", err) - } + return missing +} - // Rebuild the API clients so subsequent calls use the new token. - if err := cli.setupWithAuthentication(cmd.Context()); err != nil { - return fmt.Errorf("authentication required: %w", err) - } - } - } - } +func universalPortalsSetupCmd(cli *cli) *cobra.Command { + var inputs universalPortalsSetupInputs - ctx := cmd.Context() + cmd := &cobra.Command{ + Use: "setup", + Args: cobra.NoArgs, + Short: "Set up a Universal Portal", + Long: `Creates a Universal Portal with its application, forms and pages, and checks that the logged-in session has the permissions it needs before it creates anything. - // Resolve the portal domain: default custom domain, or fall back to the tenant domain. - domain, isCustom := resolvePortalDomain(ctx, cli.api.CustomDomain, cli.tenant) - if isCustom { - cli.renderer.Infof("Domain: %s (custom domain)", ansi.Cyan(domain)) - } else { - cli.renderer.Infof("Domain: %s", ansi.Cyan(domain)) +Running the command again after a failure resumes: what exists is reused.`, + Example: ` auth0 universal-portals setup + auth0 universal-portals setup --template business --name "Acme Admin" --user jane@acme.com + auth0 universal-portals setup --template consumer --name "Acme Account" --dry-run + auth0 up setup --no-input --template consumer --name "Acme Account" --json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runUniversalPortalsSetup(cmd, cli, inputs) + }, } - cli.renderer.Newline() - // Collect portal name. - defaultPortalName := portalName - if defaultPortalName == "" { - defaultPortalName = "My Portal" - } - if err := upPortalName.Ask(cmd, &portalName, &defaultPortalName); err != nil { - return fmt.Errorf("failed to enter portal name: %w", err) - } - if portalName == "" { - return fmt.Errorf("portal name cannot be empty") - } + upTemplate.RegisterString(cmd, &inputs.Template, "") + upName.RegisterString(cmd, &inputs.Name, "") + upSlug.RegisterString(cmd, &inputs.Slug, "") + upUser.RegisterString(cmd, &inputs.User, "") + upOrganization.RegisterString(cmd, &inputs.Organization, "") + upDryRun.RegisterBool(cmd, &inputs.DryRun, false) + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") - // Collect portal slug, defaulting to a slugified version of the portal name. - if slug == "" { - slug = toPortalSlug(portalName) - } - if err := upPortalSlug.Ask(cmd, &slug, &slug); err != nil { - return fmt.Errorf("failed to enter portal slug: %w", err) - } - if slug == "" { - return fmt.Errorf("portal slug cannot be empty") - } + return cmd +} - // The application name is derived automatically from the portal name. - appName := portalName + " (Universal Portals)" +// portalsSetupTarget is the tenant setup works on. +type portalsSetupTarget struct { + api portals.API + // The checks of the permissions are sent here. It defaults to api. + prober portals.Prober + // Whether a request changed the tenant. + hasWritten func() bool + // The requests a dry run kept. It is nil for a real run. + planned func() []plannedRequest +} - tenant := cli.tenant +// runUniversalPortalsSetup runs setup on the tenant of the CLI. A dry run reads from the tenant and +// keeps the requests that would change it. +func runUniversalPortalsSetup(cmd *cobra.Command, cli *cli, inputs universalPortalsSetupInputs) error { + tenant := newPortalsTenant(cli.api, cli.apiv3) + target := portalsSetupTarget{api: tenant, hasWritten: tenant.hasWritten} - // Ensure the resource servers required by Universal Portals exist. - type rsResult struct { - name string - existed bool - } - var rsResults []rsResult - for _, rs := range portalResourceServers(tenant) { - existed, err := ensurePortalResourceServer(ctx, cli.api.ResourceServer, rs.name, rs.identifier) + if inputs.DryRun { + dryTenant, plan, err := dryRunTenant(cli.apiAccess) if err != nil { return err } - rsResults = append(rsResults, rsResult{name: rs.name, existed: existed}) - } - cli.renderer.Successf("Resource servers ready") - for _, rs := range rsResults { - if rs.existed { - cli.renderer.Detailf("%s", ansi.Faint(rs.name+" (already exists)")) - } else { - cli.renderer.Detailf("%s", ansi.Faint(rs.name)) - } - } - // Create the portal application client. - var client portalClientResult - if err := ansi.Waiting(func() error { - var err error - client, err = createPortalClient(ctx, cli.api.Client, appName, domain, tenant, isCustom) - return err - }); err != nil { - return fmt.Errorf("failed to create application: %w", err) + // The checks of the permissions go to the tenant: they are rejected, and change nothing. + target = portalsSetupTarget{api: dryTenant, prober: tenant, hasWritten: tenant.hasWritten, planned: plan.planned} } - clientURL := portalManageClientURL(cli.tenant, cli.Config.Tenants[cli.tenant].Name, client.ClientID) - maskedSecret := client.ClientSecret[:4] + strings.Repeat("•", len(client.ClientSecret)-4) - - cli.renderer.Successf("Application %q created", appName) - cli.renderer.Detailf("Client ID: %s", ansi.Hyperlink(clientURL, ansi.Magenta(client.ClientID))) - cli.renderer.Detailf("Client secret: %s", ansi.Faint(maskedSecret)) - cli.renderer.Newline() + return runUniversalPortalsSetupOn(cmd, cli, inputs, target) +} - // Create the three client grants. - grants := buildPortalGrants(client.ClientID, tenant) - for _, g := range grants { - g := g - if err := ansi.Waiting(func() error { - return createPortalGrant(ctx, cli.apiv3.ClientGrant, g) - }); err != nil { - return fmt.Errorf("failed to create client grant for %q: %w", g.Audience, err) +// runUniversalPortalsSetupOn runs setup. It takes over the interrupt from the root command for the +// time it runs, so it can say what was and was not done when the person cancels. +func runUniversalPortalsSetupOn( + cmd *cobra.Command, + cli *cli, + inputs universalPortalsSetupInputs, + target portalsSetupTarget, +) error { + options := inputs.options() + + interactive := canPrompt(cmd) + if missing := inputs.missingForNoPrompt(); !interactive && len(missing) > 0 { + return usageError{ + err: fmt.Errorf("missing required flags for a non-interactive run: %s", strings.Join(missing, ", ")), + reason: "missing_required_flags", } } - cli.renderer.Successf("Client grants created") - for _, g := range grants { - cli.renderer.Detailf("%s", ansi.Faint(g.Audience)) - } - cli.renderer.Newline() - // Create the vault connection used by the portal forms to call the Management API. - var vaultConnID string - if err := ansi.Waiting(func() error { - setup := map[string]interface{}{ - "domain": tenant, - "client_id": client.ClientID, - "client_secret": client.ClientSecret, - "type": "OAUTH_APP", - } - appID := "AUTH0" - connName := appName - conn := &management.FlowVaultConnection{ - AppID: &appID, - Name: &connName, - Setup: &setup, - } - if err := cli.api.FlowVaultConnection.CreateConnection(ctx, conn); err != nil { - return err - } - if conn.ID != nil { - vaultConnID = *conn.ID - } - return nil - }); err != nil { - return fmt.Errorf("failed to create vault connection: %w", err) - } - cli.renderer.Successf("Vault connection created") - cli.renderer.Detailf("%s", ansi.Faint(appName)) - cli.renderer.Newline() + ctx, stop := portalsInterruptContext(cmd.Context()) + reporter := newPortalsTerminal( + cli.renderer.MessageWriter, + ansiPortalsStyle(), + cli.json || cli.renderer.AgentMode, + true, + ) + + result, err := executeUniversalPortalsSetup(ctx, universalPortalsSetup{ + options: options, + dryRun: target.planned != nil, + deps: portals.SetupDeps{ + API: target.api, + Prober: target.prober, + Reporter: reporter, + Prompter: portalsPrompter{}, + Interactive: interactive, + TenantDomain: cli.tenant, + }, + }) - // Import the portal forms. - var forms portalFormIDs - if err := ansi.Waiting(func() error { - var err error - forms, err = createPortalForms(ctx, cli.api.HTTPClient, vaultConnID) - return err - }); err != nil { - return fmt.Errorf("failed to create forms: %w", err) - } - cli.renderer.Successf("Forms created") - cli.renderer.Detailf("%s", ansi.Faint("Profile update (Universal Portals)")) - cli.renderer.Detailf("%s", ansi.Faint("Marketing communication preferences (Universal Portals)")) - cli.renderer.Detailf("%s", ansi.Faint("Privacy settings (Universal Portals)")) - cli.renderer.Newline() + interrupted := ctx.Err() != nil || errors.Is(err, portals.ErrCancelled) + stop() - // Create the portal. On slug conflict, re-prompt and retry this step only. - var portal portalResult - for { - err := ansi.Waiting(func() error { - var err error - portal, err = createPortal(ctx, cli.api.HTTPClient, slug, portalName, client.ClientID, client.ClientSecret, forms) - return err - }) - if err == nil { - break - } - var conflict *errAPIConflict - if !errors.As(err, &conflict) { - return fmt.Errorf("failed to create portal: %w", err) - } - cli.renderer.Warnf("Slug %q is already taken.", slug) - prevSlug := slug - slug = "" - if err := upPortalSlug.Ask(cmd, &slug, &prevSlug); err != nil { - return fmt.Errorf("failed to enter portal slug: %w", err) - } + if interrupted { + reportPortalsCancelled(reporter, target.hasWritten()) + os.Exit(exitInterrupted) } - cli.renderer.Successf("Portal %q created", portal.Name) - portalURL := "https://" + domain + "/portals/" + portal.Slug + if err != nil { + return portalsCommandError(err, target.hasWritten()) + } - cli.renderer.Newline() - cli.renderer.Infof("Portal: %s", ansi.Cyan(portalURL)) - cli.renderer.Newline() + // A dry run created nothing, so there is no portal to open. + if target.planned != nil { + reportPortalsPlan(cli, reporter, result, target.planned()) - if cli.noInput { return nil } - cli.renderer.Infof("%s to open the portal or %s to quit...", ansi.Green("Press Enter"), ansi.Red("^C")) - if _, err := fmt.Scanln(); err != nil { + if cli.json { + cli.renderer.JSONResult(result) + return nil } - if err := browser.OpenURL(portalURL); err != nil { - cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s", portalURL) + reporter.Ready(result.Portal.URL) + + if interactive { + openPortal(cli, result.Portal.URL) } return nil } -// portalResourceServer describes a resource server that Universal Portals requires. -type portalResourceServer struct { - name string - identifier string -} - -// portalResourceServers returns the two resource servers required by Universal Portals. -func portalResourceServers(tenant string) []portalResourceServer { - return []portalResourceServer{ - {name: "Auth0 My Account API", identifier: "https://" + tenant + "/me/"}, - {name: "Auth0 My Organization API", identifier: "https://" + tenant + "/my-org/"}, +// reportPortalsPlan shows the result of a dry run: the result of a real run, and the requests it +// would send in the order it would send them. +func reportPortalsPlan(cli *cli, reporter portals.Reporter, result portals.SetupResult, planned []plannedRequest) { + if planned == nil { + planned = []plannedRequest{} } -} - -// resolvePortalDomain returns the domain for portal URLs. -// Uses the default custom domain if active; falls back to tenantDomain. -func resolvePortalDomain(ctx context.Context, api auth0.CustomDomainAPI, tenantDomain string) (domain string, isCustom bool) { - cd, err := api.ReadDefault(ctx) - if err == nil && cd.GetStatus() == "ready" { - return cd.GetDomain(), true - } - return tenantDomain, false -} -// ensurePortalResourceServer creates a resource server idempotently. -// Returns (true, nil) when the server already existed, (false, nil) when created. -func ensurePortalResourceServer(ctx context.Context, api auth0.ResourceServerAPI, name, identifier string) (alreadyExisted bool, err error) { - skipConsent := true - tokenDialect := "rfc9068_profile" + if cli.json { + cli.renderer.JSONResult(dryRunResult{DryRun: true, SetupResult: result, Requests: planned}) - err = api.Create(ctx, &management.ResourceServer{ - Name: &name, - Identifier: &identifier, - SkipConsentForVerifiableFirstPartyClients: &skipConsent, - TokenDialect: &tokenDialect, - }) - if err == nil { - return false, nil - } - if mErr, ok := err.(management.Error); ok && mErr.Status() == http.StatusConflict { - return true, nil + return } - return false, fmt.Errorf("failed to ensure resource server %q: %w", name, err) -} -type portalClientResult struct { - ClientID string - ClientSecret string + reporter.Result(fmt.Sprintf("Dry run complete. %d request(s) would change the tenant.", len(planned))) + reporter.Detail("Run the same command without --dry-run to set up the portal.") } -// createPortalClient creates the portal application client via the typed SDK. -// Backchannel logout is omitted when isCustomDomain is false because -// Auth0 domains are rejected by the payload validation. -func createPortalClient(ctx context.Context, api auth0.ClientAPI, name, domain, tenant string, isCustomDomain bool) (portalClientResult, error) { - myAccountScopes := myAccountAPIScopes - myOrgScopes := myOrgAPIScopes - - c := &management.Client{ - Name: auth0.String(name), - IsFirstParty: auth0.Bool(true), - AppType: auth0.String("regular_web"), - OIDCConformant: auth0.Bool(true), - TokenEndpointAuthMethod: auth0.String("client_secret_post"), - Callbacks: &[]string{"https://" + domain + "/portals/auth/callback"}, - AllowedLogoutURLs: &[]string{"https://" + domain}, - OrganizationRequireBehavior: auth0.String("no_prompt"), - OrganizationUsage: auth0.String("allow"), - GrantTypes: &portalGrantTypes, - RefreshToken: &management.ClientRefreshToken{ - ExpirationType: auth0.String("expiring"), - Leeway: auth0.Int(0), - InfiniteTokenLifetime: auth0.Bool(false), - InfiniteIdleTokenLifetime: auth0.Bool(true), - TokenLifetime: auth0.Int(86400), - IdleTokenLifetime: auth0.Int(86399), - RotationType: auth0.String("non-rotating"), - Policies: &[]management.ClientRefreshTokenPolicy{ - {Audience: auth0.String("https://" + tenant + "/me/"), Scope: &myAccountScopes}, - {Audience: auth0.String("https://" + tenant + "/my-org/"), Scope: &myOrgScopes}, - }, - }, - } - - if isCustomDomain { - initiators := []string{ - "idp-logout", "rp-logout", "session-expired", - "session-revoked", "account-deleted", "account-deactivated", - } - c.OIDCLogout = &management.OIDCLogout{ - BackChannelLogoutURLs: &[]string{"https://" + domain + "/portals/auth/backchannel-logout"}, - BackChannelLogoutInitiators: &management.BackChannelLogoutInitiators{ - Mode: auth0.String("custom"), - SelectedInitiators: &initiators, - }, - } - } - - if err := api.Create(ctx, c); err != nil { - return portalClientResult{}, err - } - return portalClientResult{ - ClientID: c.GetClientID(), - ClientSecret: c.GetClientSecret(), - }, nil +// universalPortalsSetup is one run of setup. +type universalPortalsSetup struct { + options portals.SetupOptions + deps portals.SetupDeps + dryRun bool } -// buildPortalGrants returns the three grants required by Universal Portals. -// Pure function: no I/O, fully testable. -func buildPortalGrants(clientID, tenant string) []*managementv3.CreateClientGrantRequestContent { - myAccountScopes := myAccountAPIScopes - myOrgScopes := myOrgAPIScopes - mgmtScopes := managementAPIScopes - userSubject := managementv3.ClientGrantSubjectTypeEnumUser - clientSubject := managementv3.ClientGrantSubjectTypeEnumClient +// executeUniversalPortalsSetup shows the title, asks for what is missing, and sets the portal up. +func executeUniversalPortalsSetup(ctx context.Context, run universalPortalsSetup) (portals.SetupResult, error) { + run.deps.Reporter.Banner("Universal Portals setup", run.deps.TenantDomain) - return []*managementv3.CreateClientGrantRequestContent{ - {ClientID: auth0.String(clientID), Audience: "https://" + tenant + "/me/", SubjectType: &userSubject, Scope: myAccountScopes}, - {ClientID: auth0.String(clientID), Audience: "https://" + tenant + "/my-org/", SubjectType: &userSubject, Scope: myOrgScopes}, - {ClientID: auth0.String(clientID), Audience: "https://" + tenant + "/api/v2/", SubjectType: &clientSubject, Scope: mgmtScopes}, + if run.dryRun { + run.deps.Reporter.Info("Dry run: nothing is created or changed. Statuses show what a real run would do.") } -} - -// createPortalGrant creates a single client grant via the typed SDK. -func createPortalGrant(ctx context.Context, api auth0.ClientGrantAPIV3, grant *managementv3.CreateClientGrantRequestContent) error { - _, err := api.Create(ctx, grant) - return err -} - -// errAPIConflict is returned by rawAPIPost when the server responds 409. -// Callers that want to handle conflicts (e.g. slug already taken) can use -// errors.As to detect and recover from this case. -type errAPIConflict struct{ message string } -func (e *errAPIConflict) Error() string { return e.message } + options := run.options -// rawAPIPost is a deep helper that hides the New/Do/decode HTTP pattern. -// Pass path as one or more segments — they are joined by the URI builder so -// slashes are not URL-encoded. Pass a non-nil result to decode the response. -func rawAPIPost(ctx context.Context, h auth0.HTTPClientAPI, payload, result any, path ...string) error { - req, err := h.NewRequest(ctx, http.MethodPost, h.URI(path...), payload) - if err != nil { - return err - } - - resp, err := h.Do(req) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() + if run.deps.Interactive { + var err error - if resp.StatusCode >= http.StatusBadRequest { - var apiErr struct { - StatusCode int `json:"statusCode"` - Message string `json:"message"` + if options, err = portals.AskForMissing(options, run.deps.Prompter); err != nil { + return portals.SetupResult{}, err } - _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if resp.StatusCode == http.StatusConflict { - return &errAPIConflict{message: apiErr.Message} - } - return fmt.Errorf("API error %d: %s", resp.StatusCode, apiErr.Message) - } - - if result != nil { - return json.NewDecoder(resp.Body).Decode(result) } - return nil -} -type portalFormResult struct { - Form struct { - ID string `json:"id"` - } `json:"form"` + return portals.Setup(ctx, options, run.deps) } -// buildCommunicationPreferencesFormPayload returns the form document for the -// "Marketing communication preferences" form with connID injected. -func buildCommunicationPreferencesFormPayload(connID string) map[string]any { - return map[string]any{ - "version": "4.0.0", - "form": map[string]any{ - "name": "Marketing communication preferences (Universal Portals)", - "languages": map[string]any{"primary": "en"}, - "nodes": []any{ - map[string]any{ - "id": "step_4Td2", - "type": "STEP", - "coordinates": map[string]any{"x": 217, "y": -218}, - "config": map[string]any{ - "components": []any{ - map[string]any{ - "id": "communication_preferences", - "category": "FIELD", - "type": "CHOICE", - "required": false, - "sensitive": false, - "config": map[string]any{ - "multiple": true, - "options": []any{ - map[string]any{"label": "Product Announcements", "value": "Product Announcements"}, - map[string]any{"label": "Featured Content", "value": "Featured Content"}, - map[string]any{"label": "Digest News", "value": "Digest News"}, - map[string]any{"label": "Events", "value": "Events"}, - }, - }, - }, - map[string]any{ - "id": "next_button_US5N", - "category": "BLOCK", - "type": "NEXT_BUTTON", - "config": map[string]any{"text": "Update"}, - }, - }, - "next_node": "flow_etvH", - }, - }, - map[string]any{ - "id": "flow_etvH", - "type": "FLOW", - "coordinates": map[string]any{"x": 791, "y": -81}, - "config": map[string]any{"flow_id": "#FLOW-1#", "next_node": "$ending"}, - }, - }, - "start": map[string]any{"next_node": "step_4Td2", "coordinates": map[string]any{"x": -25, "y": -98}}, - "ending": map[string]any{"resume_flow": true, "coordinates": map[string]any{"x": 1194, "y": -59}}, - }, - "flows": map[string]any{ - "#FLOW-1#": map[string]any{ - "name": "Update preferences", - "actions": []any{ - map[string]any{ - "id": "update_user_HitP", - "type": "AUTH0", - "action": "UPDATE_USER", - "allow_failure": false, - "mask_output": false, - "params": map[string]any{ - "connection_id": "#CONN-1#", - "user_id": "{{context.user.user_id}}", - "changes": map[string]any{ - "app_metadata": map[string]any{ - "communication_preferences": "{{fields.communication_preferences}}", - }, - }, - }, - }, - }, - }, - }, - "connections": map[string]any{ - "#CONN-1#": map[string]any{"id": connID}, - }, +// openPortal offers to open the portal in the browser. A cancel means no: the portal is already set +// up, so cancelling this question is not a cancelled setup. +func openPortal(cli *cli, url string) { + open, err := portalsPrompter{}.Confirm("Open the portal in your browser?", true) + if err != nil || !open { + return } -} -// buildPersonalInfoFormPayload returns the form document for the -// "Profile update" form with connID injected. -func buildPersonalInfoFormPayload(connID string) map[string]any { - textField := func(id, label string) map[string]any { - return map[string]any{ - "id": id, - "category": "FIELD", - "type": "TEXT", - "label": label, - "required": false, - "sensitive": false, - "config": map[string]any{"multiline": false}, - } - } - return map[string]any{ - "version": "4.0.0", - "form": map[string]any{ - "name": "Profile update (Universal Portals)", - "languages": map[string]any{"primary": "en"}, - "nodes": []any{ - map[string]any{ - "id": "step_BL3V", - "type": "STEP", - "coordinates": map[string]any{"x": 346, "y": -221}, - "config": map[string]any{ - "components": []any{ - textField("full_name", "Full name"), - textField("job_title", "Job title"), - map[string]any{ - "id": "mobile_number", - "category": "FIELD", - "type": "TEL", - "label": "Mobile number", - "required": false, - "sensitive": false, - "config": map[string]any{"country_picker": true}, - }, - map[string]any{ - "id": "date_of_birth", - "category": "FIELD", - "type": "DATE", - "label": "Date of birth", - "required": false, - "sensitive": false, - "config": map[string]any{"format": "DATE"}, - }, - map[string]any{ - "id": "linkedin", - "category": "FIELD", - "type": "URL", - "label": "LinkedIn", - "required": false, - "sensitive": false, - }, - map[string]any{ - "id": "next_button_UmEY", - "category": "BLOCK", - "type": "NEXT_BUTTON", - "config": map[string]any{"text": "Update"}, - }, - }, - "next_node": "flow_DcHK", - }, - }, - map[string]any{ - "id": "flow_DcHK", - "type": "FLOW", - "coordinates": map[string]any{"x": 988, "y": 9}, - "config": map[string]any{"flow_id": "#FLOW-1#", "next_node": "$ending"}, - }, - }, - "start": map[string]any{"next_node": "step_BL3V", "coordinates": map[string]any{"x": 0, "y": 0}}, - "ending": map[string]any{"resume_flow": true, "coordinates": map[string]any{"x": 1367, "y": -7}}, - }, - "flows": map[string]any{ - "#FLOW-1#": map[string]any{ - "name": "Update profile", - "actions": []any{ - map[string]any{ - "id": "update_user_v4C0", - "type": "AUTH0", - "action": "UPDATE_USER", - "allow_failure": false, - "mask_output": false, - "params": map[string]any{ - "connection_id": "#CONN-1#", - "user_id": "{{context.user.user_id}}", - "changes": map[string]any{ - "user_metadata": map[string]any{ - "linkedin": "{{fields.linkedin}}", - "full_name": "{{fields.full_name}}", - "job_title": "{{fields.job_title}}", - "date_of_birth": "{{fields.date_of_birth}}", - "mobile_number": "{{fields.mobile_number.international_number}}", - }, - }, - }, - }, - }, - }, - }, - "connections": map[string]any{ - "#CONN-1#": map[string]any{"id": connID}, - }, + if err := browser.OpenURL(url); err != nil { + cli.renderer.Warnf("Could not open the browser: %s\nOpen the portal yourself: %s", err, ansi.URL(url)) } } -// buildPrivacySettingsFormPayload returns the form document for the -// "Privacy settings" form with connID injected. -func buildPrivacySettingsFormPayload(connID string) map[string]any { - boolField := func(id, label, hint string) map[string]any { - return map[string]any{ - "id": id, - "category": "FIELD", - "type": "BOOLEAN", - "label": label, - "hint": hint, - "required": true, - "sensitive": false, - "config": map[string]any{"default_value": false}, - } - } - return map[string]any{ - "version": "4.0.0", - "form": map[string]any{ - "name": "Privacy settings (Universal Portals)", - "languages": map[string]any{"primary": "en"}, - "nodes": []any{ - map[string]any{ - "id": "step_y0Ri", - "type": "STEP", - "coordinates": map[string]any{"x": 500, "y": 0}, - "config": map[string]any{ - "components": []any{ - boolField("data_sharing", "Data sharing", `

Allow app usage data to improve features. Learn more.

`), - boolField("profile_visibility", "Profile visibility", `

Show my profile in search results. Learn more.

`), - boolField("location_tracking", "Location tracking", `

Allow location tracking for personalized recommendations. Learn more.

`), - boolField("ad_personalization", "Ad personalization", `

Use my data to personalize ads. Learn more.

`), - map[string]any{ - "id": "next_button_aB8e", - "category": "BLOCK", - "type": "NEXT_BUTTON", - "config": map[string]any{"text": "Update"}, - }, - }, - "next_node": "flow_AB1r", - }, - }, - map[string]any{ - "id": "flow_AB1r", - "type": "FLOW", - "coordinates": map[string]any{"x": 1043, "y": 261}, - "config": map[string]any{"flow_id": "#FLOW-1#", "next_node": "$ending"}, - }, - }, - "start": map[string]any{"next_node": "step_y0Ri", "coordinates": map[string]any{"x": 202, "y": 247}}, - "ending": map[string]any{"resume_flow": true, "coordinates": map[string]any{"x": 1445, "y": 247}}, - }, - "flows": map[string]any{ - "#FLOW-1#": map[string]any{ - "name": "Update privacy settings", - "actions": []any{ - map[string]any{ - "id": "update_user_uOZW", - "type": "AUTH0", - "action": "UPDATE_USER", - "allow_failure": false, - "mask_output": false, - "params": map[string]any{ - "connection_id": "#CONN-1#", - "user_id": "{{context.user.user_id}}", - "changes": map[string]any{ - "app_metadata": map[string]any{ - "data_sharing": "{{fields.data_sharing}}", - "location_tracking": "{{fields.location_tracking}}", - "ad_personalization": "{{fields.ad_personalization}}", - "profile_visibility": "{{fields.profile_visibility}}", - }, - }, - }, - }, - }, - }, - }, - "connections": map[string]any{ - "#CONN-1#": map[string]any{"id": connID}, - }, - } -} - -// createPortalForms imports the portal forms and returns their IDs. -// Uses POST /api/v2/forms/import which accepts the full form document -// (version, form, flows, connections). The URI is built with two segments -// to avoid h.URI encoding the slash as %2F. -func createPortalForms(ctx context.Context, h auth0.HTTPClientAPI, vaultConnID string) (portalFormIDs, error) { - importForm := func(payload map[string]any) (string, error) { - var result portalFormResult - if err := rawAPIPost(ctx, h, payload, &result, "forms", "import"); err != nil { - return "", err - } - return result.Form.ID, nil - } - - personalInfoID, err := importForm(buildPersonalInfoFormPayload(vaultConnID)) - if err != nil { - return portalFormIDs{}, fmt.Errorf("failed to create personal info form: %w", err) - } - - commPrefID, err := importForm(buildCommunicationPreferencesFormPayload(vaultConnID)) - if err != nil { - return portalFormIDs{}, fmt.Errorf("failed to create communication preferences form: %w", err) - } - - privacyID, err := importForm(buildPrivacySettingsFormPayload(vaultConnID)) - if err != nil { - return portalFormIDs{}, fmt.Errorf("failed to create privacy settings form: %w", err) +// reportPortalsCancelled says what a cancel left in the tenant. +func reportPortalsCancelled(reporter portals.Reporter, wrote bool) { + if wrote { + reporter.Warn("Cancelled. Nothing was rolled back: what was already created is still in your tenant.") + reporter.Warn(portalsResumeHint) + } else { + reporter.Warn("Cancelled. Nothing was created.") } - return portalFormIDs{ - PersonalInfo: personalInfoID, - CommunicationPreferences: commPrefID, - PrivacyConsent: privacyID, - }, nil -} - -type portalPayload struct { - Slug string `json:"slug"` - Name string `json:"name"` - Client portalClientRef `json:"client"` - Navigation *portalNavigation `json:"navigation,omitempty"` - Pages *portalPages `json:"pages,omitempty"` -} - -type portalClientRef struct { - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` -} - -type portalNavigation struct { - Sidebar portalSidebar `json:"sidebar"` -} - -type portalSidebar struct { - Components []portalComponent `json:"components"` -} - -type portalPages struct { - Default string `json:"default,omitempty"` - Standard []portalPage `json:"standard"` -} - -type portalPage struct { - Title string `json:"title"` - Slug string `json:"slug"` - Components []portalComponent `json:"components,omitempty"` -} - -// portalComponent covers both sidebar and page components. -// Config is map[string]any because component types have heterogeneous shapes. -type portalComponent struct { - Type string `json:"type"` - Config map[string]any `json:"config,omitempty"` + reporter.Cancelled("Setup cancelled.") } -// portalResult holds the fields read back from the create-portal response. -type portalResult struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` -} +// portalsInterruptContext returns a context that ends when the process is interrupted. The root +// command exits at once on an interrupt, which would cut the command off before it could say what it +// left behind, so the command takes the signal over for the time it runs. +func portalsInterruptContext(parent context.Context) (context.Context, func()) { + ctx, cancel := context.WithCancel(parent) -// toPortalSlug derives a URL-safe kebab-case slug from a portal name. -// Pure function: no I/O. -func toPortalSlug(name string) string { - var b strings.Builder - prevHyphen := false - for _, r := range strings.ToLower(name) { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9': - b.WriteRune(r) - prevHyphen = false - case b.Len() > 0 && !prevHyphen: - b.WriteByte('-') - prevHyphen = true - } - } - slug := strings.TrimRight(b.String(), "-") - if slug == "" { - return "my-portal" - } - return slug -} + signal.Reset(os.Interrupt, syscall.SIGTERM) -// portalFormIDs holds the IDs of the three forms required by the portal. -type portalFormIDs struct { - PersonalInfo string - PrivacyConsent string - CommunicationPreferences string -} + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) -// buildDefaultPortal returns the full portal payload matching the Universal Portals -// template: four pages with all sections, including form-backed ones. -// Pure function: no I/O. -func buildDefaultPortal(slug, name, clientID, clientSecret string, forms portalFormIDs) portalPayload { - section := func(title, description string, children ...portalComponent) portalComponent { - return portalComponent{ - Type: "page:component:auth0:structure:section", - Config: map[string]any{ - "title": title, - "description": description, - "variant": "card", - "children": children, - }, - } - } - builtin := func(componentType string) portalComponent { - return portalComponent{Type: componentType} - } - form := func(formID, completionMessage string) portalComponent { - return portalComponent{ - Type: "page:component:auth0:form", - Config: map[string]any{ - "form_id": formID, - "completion_message": completionMessage, - }, - } - } - navLink := func(label, to, icon string) portalComponent { - return portalComponent{ - Type: "sidebar:component:auth0:internal_link", - Config: map[string]any{"label": label, "to": to, "icon": icon}, + go func() { + select { + case <-signals: + cancel() + case <-ctx.Done(): } - } - - return portalPayload{ - Slug: slug, - Name: name, - Client: portalClientRef{ - TokenEndpointAuthMethod: "client_secret_post", - ClientID: clientID, - ClientSecret: clientSecret, - }, - Navigation: &portalNavigation{ - Sidebar: portalSidebar{ - Components: []portalComponent{ - navLink("Profile", "profile", "user"), - navLink("Security", "security", "shield"), - navLink("Organization", "organization", "building"), - navLink("Legal & privacy", "legal-privacy", "file-text"), - }, - }, - }, - Pages: &portalPages{ - Default: "profile", - Standard: []portalPage{ - { - Title: "Profile", - Slug: "profile", - Components: []portalComponent{ - section( - "Personal information", - "Basic info about you, like your name and contact details, that you use across services.", - form(forms.PersonalInfo, "Your personal information has been updated."), - ), - section( - "Passkeys", - "Use your fingerprint, face, or screen lock instead of a password to sign in quickly and more securely.", - builtin("page:component:auth0:my_account:passkey_management"), - ), - }, - }, - { - Title: "Security", - Slug: "security", - Components: []portalComponent{ - section( - "Multi-factor authentication", - "Add an extra layer of protection to your account by requiring a second verification step each time you sign in.", - builtin("page:component:auth0:my_account:mfa_management"), - ), - section( - "Sessions & devices", - "Review the devices and sessions that are currently signed in to your account.", - portalComponent{ - Type: "page:component:auth0:typography:rich_text", - Config: map[string]any{"content": "

Sessions & devices management coming soon.

"}, - }, - ), - }, - }, - { - Title: "Organization", - Slug: "organization", - Components: []portalComponent{ - section( - "Organization details", - "Update your organization's name and other details visible to its members.", - builtin("page:component:auth0:my_organization:details_edit"), - ), - }, - }, - { - Title: "Legal & privacy", - Slug: "legal-privacy", - Components: []portalComponent{ - section( - "Privacy & data consent", - "Control how your personal data is collected and used across our services.", - form(forms.PrivacyConsent, "Your privacy preferences have been saved."), - ), - section( - "Communication preferences", - "Choose which emails and notifications you'd like to receive from us.", - form(forms.CommunicationPreferences, "Your communication preferences have been updated."), - ), - }, - }, - }, - }, - } -} - -// createPortal POSTs a new portal with the full default page structure. -func createPortal(ctx context.Context, h auth0.HTTPClientAPI, slug, name, clientID, clientSecret string, forms portalFormIDs) (portalResult, error) { - payload := buildDefaultPortal(slug, name, clientID, clientSecret, forms) - var result portalResult - if err := rawAPIPost(ctx, h, payload, &result, "portals"); err != nil { - return portalResult{}, err - } - return result, nil -} + }() -// portalManageClientURL builds the Management Dashboard URL for the given client. -// Pure function: takes only the values it needs, no struct access. -func portalManageClientURL(tenantDomain, tenantName, clientID string) string { - parts := strings.Split(tenantDomain, ".") - region := "us" - if len(parts) > 3 { - region = parts[1] + return ctx, func() { + signal.Stop(signals) + cancel() } - return fmt.Sprintf("%s/dashboard/%s/%s/applications/%s/settings", - deriveServiceURL("manage", tenantDomain), region, tenantName, clientID) } diff --git a/internal/cli/universal_portals_audit.go b/internal/cli/universal_portals_audit.go new file mode 100644 index 000000000..de09dced0 --- /dev/null +++ b/internal/cli/universal_portals_audit.go @@ -0,0 +1,154 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/portals" +) + +var ( + upAuditOrganization = Flag{ + Name: "Organization", + LongForm: "organization", + ShortForm: "o", + Help: "Business only: the ID of the organization the portal opens in, to check that it shares a connection with the application.", + } + upFix = Flag{ + Name: "Fix", + LongForm: "fix", + Help: "Apply the errors the CLI can fix, without asking. Warnings are never changed.", + } + upStrict = Flag{ + Name: "Strict", + LongForm: "strict", + Help: "Also fail when there are warnings.", + } +) + +// universalPortalsAuditInputs are the flags of audit. +type universalPortalsAuditInputs struct { + Organization string + Fix bool + Strict bool +} + +func universalPortalsAuditCmd(cli *cli) *cobra.Command { + var inputs universalPortalsAuditInputs + + cmd := &cobra.Command{ + Use: "audit ", + Short: "Check a Universal Portal", + Long: `Checks a Universal Portal and its application against what setup makes, and tells what is wrong and how to fix it. + +An error breaks the portal or one of its components, and a warning is something degraded or different from the setup guide. The command fails when there is an error, and with --strict when there is a warning too. + +With a terminal it offers to fix the errors the CLI can fix, and with --fix it fixes them without asking. Warnings are never changed, because they may be intentional.`, + Example: ` auth0 universal-portals audit up_123 + auth0 universal-portals audit up_123 --fix + auth0 up audit up_123 --organization org_123 --strict --json`, + Args: func(_ *cobra.Command, args []string) error { + if len(args) != 1 { + return usageError{ + err: errors.New("audit needs the ID of one portal: auth0 universal-portals audit [--organization ] [--strict]"), + reason: "missing_required_args", + } + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runUniversalPortalsAudit(cmd, cli, args[0], inputs) + }, + } + + upAuditOrganization.RegisterString(cmd, &inputs.Organization, "") + upFix.RegisterBool(cmd, &inputs.Fix, false) + upStrict.RegisterBool(cmd, &inputs.Strict, false) + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + + return cmd +} + +// runUniversalPortalsAudit audits a portal of the tenant of the CLI. +func runUniversalPortalsAudit(cmd *cobra.Command, cli *cli, portalID string, inputs universalPortalsAuditInputs) error { + return runUniversalPortalsAuditOn(cmd, cli, portalID, inputs, newPortalsTenant(cli.api, cli.apiv3)) +} + +// runUniversalPortalsAuditOn runs an audit. It takes over the interrupt from the root command for the +// time it runs, so it can say what was and was not done when the person cancels. +func runUniversalPortalsAuditOn( + cmd *cobra.Command, + cli *cli, + portalID string, + inputs universalPortalsAuditInputs, + api portals.API, +) error { + ctx, stop := portalsInterruptContext(cmd.Context()) + reporter := newPortalsTerminal( + cli.renderer.MessageWriter, + ansiPortalsStyle(), + cli.json || cli.renderer.AgentMode, + true, + ) + + outcome, err := portals.Audit(ctx, portals.AuditOptions{ + PortalID: portalID, + Organization: inputs.Organization, + Strict: inputs.Strict, + Fix: inputs.Fix, + }, portals.AuditDeps{ + API: api, + Reporter: reporter, + Prompter: portalsPrompter{}, + Interactive: canPrompt(cmd), + TenantDomain: cli.tenant, + }) + + interrupted := ctx.Err() != nil || errors.Is(err, portals.ErrCancelled) + stop() + + if interrupted { + reporter.Warn("Cancelled. Fixes already applied stay in your tenant.") + reporter.Cancelled("Audit cancelled.") + os.Exit(exitInterrupted) + } + + if err != nil { + return portalsCommandError(err, false) + } + + if cli.json { + cli.renderer.JSONResult(outcome.Result) + } + + if outcome.Failed() { + return validationError{err: auditFailure(outcome, inputs.Strict), reason: "audit_failed"} + } + + return nil +} + +// auditFailure says why an audit failed. The findings were shown already, so this is the short reason +// for the exit code. +func auditFailure(outcome portals.AuditOutcome, strict bool) error { + var reasons []string + + if outcome.Result.Errors > 0 { + reasons = append(reasons, fmt.Sprintf("%d error(s)", outcome.Result.Errors)) + } + + if strict && outcome.Result.Warnings > 0 { + reasons = append(reasons, fmt.Sprintf("%d warning(s) in a strict audit", outcome.Result.Warnings)) + } + + if outcome.FixesFailed > 0 { + reasons = append(reasons, fmt.Sprintf("%d fix(es) failed", outcome.FixesFailed)) + } + + return fmt.Errorf("the audit failed: %s", strings.Join(reasons, ", ")) +} diff --git a/internal/cli/universal_portals_audit_test.go b/internal/cli/universal_portals_audit_test.go new file mode 100644 index 000000000..4a7e22de0 --- /dev/null +++ b/internal/cli/universal_portals_audit_test.go @@ -0,0 +1,267 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" +) + +// auditRun is a run of the audit command on a fake tenant that holds a portal made by setup. +type auditRun struct { + *setupRun + portalID string + clientID string +} + +func newAuditRun(t *testing.T, json bool) *auditRun { + t.Helper() + + setup := newSetupRun(json) + setup.tenant.AddConnection(portals.Connection{ID: "con_db", Name: "Username-Password-Authentication"}, "auth0") + + require.NoError(t, setup.run(consumerInputs)) + + setup.messages.Reset() + setup.results.Reset() + + portal := setup.tenant.Portals()[0] + setup.tenant.EnableClientConnection(portal.Client.ClientID, "con_db") + + return &auditRun{setupRun: setup, portalID: portal.ID, clientID: portal.Client.ClientID} +} + +func (r *auditRun) audit(inputs universalPortalsAuditInputs) error { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + return runUniversalPortalsAuditOn(cmd, r.cli, r.portalID, inputs, r.tenant) +} + +func TestAuditCommand_APortalThatPasses(t *testing.T) { + t.Parallel() + + t.Run("JSON is the result, with the fields and the order of the tool this replaces", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, true) + + require.NoError(t, run.audit(universalPortalsAuditInputs{})) + + var reindented bytes.Buffer + require.NoError(t, json.Indent(&reindented, run.results.Bytes(), "", " ")) + assert.Equal(t, "{\n \"portalId\": \""+run.portalID+"\",\n \"findings\": [],\n \"errors\": 0,\n \"warnings\": 0,\n \"failed\": false\n}", strings.TrimSpace(reindented.String())) + assert.Empty(t, run.messages.String(), "the result is alone on its stream") + }) + + t.Run("a person sees the box, the portal and the summary", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, false) + + require.NoError(t, run.audit(universalPortalsAuditInputs{})) + + lines := strings.Split(run.messages.String(), "\n") + assert.Equal(t, "│ ╭─Universal Portals audit──╮", lines[0]) + assert.Contains(t, run.messages.String(), `◇ Portal "acme-account"`) + assert.Contains(t, run.messages.String(), "No problems found") + assert.Contains(t, run.messages.String(), "0 errors, 0 warnings") + assert.Empty(t, run.results.String()) + }) +} + +func TestAuditCommand_APortalThatFails(t *testing.T) { + t.Parallel() + + breakIt := func(run *auditRun) { + run.tenant.ModifyClient(run.clientID, func(client *portals.Client) { client.RefreshToken.RotationType = "rotating" }) + } + + t.Run("it exits with a validation error, and still prints the result", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, true) + breakIt(run) + + err := run.audit(universalPortalsAuditInputs{}) + + require.Error(t, err) + assert.Equal(t, "the audit failed: 1 error(s)", err.Error()) + assert.Equal(t, "validation", errorClass(err)) + assert.Equal(t, "audit_failed", errorReason(err)) + + var printed portals.AuditResult + require.NoError(t, json.Unmarshal(run.results.Bytes(), &printed)) + assert.True(t, printed.Failed) + assert.Equal(t, "P013", printed.Findings[0].ID) + }) + + t.Run("--fix fixes it and the audit passes", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, true) + breakIt(run) + + require.NoError(t, run.audit(universalPortalsAuditInputs{Fix: true})) + + var printed portals.AuditResult + require.NoError(t, json.Unmarshal(run.results.Bytes(), &printed)) + assert.Empty(t, printed.Findings) + + client, err := run.tenant.ReadClient(context.Background(), run.clientID) + require.NoError(t, err) + assert.Equal(t, "non-rotating", client.RefreshToken.RotationType) + }) + + t.Run("a fix that failed is why it failed", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, true) + breakIt(run) + run.tenant.Deny("PATCH clients/"+run.clientID, "Insufficient scope, expected any of: update:clients") + + err := run.audit(universalPortalsAuditInputs{Fix: true}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "1 error(s), 1 fix(es) failed") + }) +} + +func TestAuditCommand_Warnings(t *testing.T) { + t.Parallel() + + warn := func(run *auditRun) { + run.tenant.ModifyClient(run.clientID, func(client *portals.Client) { client.IPHeaderTrusted = boolPointer(false) }) + } + + t.Run("a warning does not fail the audit", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, true) + warn(run) + + require.NoError(t, run.audit(universalPortalsAuditInputs{})) + }) + + t.Run("a warning fails a strict audit", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, true) + warn(run) + + err := run.audit(universalPortalsAuditInputs{Strict: true}) + + require.Error(t, err) + assert.Equal(t, "the audit failed: 1 warning(s) in a strict audit", err.Error()) + }) + + t.Run("--fix does not change a warning", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, true) + warn(run) + + require.NoError(t, run.audit(universalPortalsAuditInputs{Fix: true})) + + client, err := run.tenant.ReadClient(context.Background(), run.clientID) + require.NoError(t, err) + assert.False(t, *client.IPHeaderTrusted) + }) +} + +func TestAuditCommand_Errors(t *testing.T) { + t.Parallel() + + t.Run("a portal that does not exist is a not found error", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, true) + run.portalID = "up_missing" + + err := run.audit(universalPortalsAuditInputs{}) + + require.Error(t, err) + assert.Equal(t, "not_found", errorClass(err)) + assert.Equal(t, 404, errorHTTPStatus(err)) + assert.Empty(t, run.results.String()) + }) + + t.Run("a missing scope is an auth error that lists how to get it", func(t *testing.T) { + t.Parallel() + + run := newAuditRun(t, true) + run.tenant.Deny("GET portals/"+run.portalID, "Insufficient scope, expected any of: read:portals") + + err := run.audit(universalPortalsAuditInputs{}) + + require.Error(t, err) + assert.Equal(t, "auth", errorClass(err)) + assert.Equal(t, "insufficient_scope", errorReason(err)) + + var details struct { + Fixes []string `json:"fixes"` + } + require.NoError(t, json.Unmarshal(buildErrorEnvelope(err).Error.Details, &details)) + assert.Equal(t, portals.ScopeFixes([]string{"read:portals"}), details.Fixes) + }) + + t.Run("the command needs one portal ID", func(t *testing.T) { + t.Parallel() + + cmd := universalPortalsAuditCmd(&cli{}) + + for _, args := range [][]string{nil, {"up_1", "up_2"}} { + err := cmd.Args(cmd, args) + + require.Error(t, err) + assert.Equal(t, "usage", errorClass(err)) + assert.Equal(t, "missing_required_args", errorReason(err)) + assert.Contains(t, err.Error(), "auth0 universal-portals audit ") + } + + assert.NoError(t, cmd.Args(cmd, []string{"up_1"})) + }) +} + +func TestAuditFailure(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + outcome portals.AuditOutcome + strict bool + want string + }{ + { + name: "errors", + outcome: portals.AuditOutcome{Result: portals.AuditResult{Errors: 2}}, + want: "the audit failed: 2 error(s)", + }, + { + name: "errors and a fix that failed", + outcome: portals.AuditOutcome{Result: portals.AuditResult{Errors: 1}, FixesFailed: 1}, + want: "the audit failed: 1 error(s), 1 fix(es) failed", + }, + { + name: "warnings count in a strict audit", + outcome: portals.AuditOutcome{Result: portals.AuditResult{Warnings: 3}}, + strict: true, + want: "the audit failed: 3 warning(s) in a strict audit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.EqualError(t, auditFailure(tt.outcome, tt.strict), tt.want) + }) + } +} diff --git a/internal/cli/universal_portals_contract_test.go b/internal/cli/universal_portals_contract_test.go new file mode 100644 index 000000000..3de87fef2 --- /dev/null +++ b/internal/cli/universal_portals_contract_test.go @@ -0,0 +1,120 @@ +package cli + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +// The in-memory tenant of the flow tests and the adapter over the SDK are two implementations of the +// same stores, so what a flow test sees must be what the API gets. ClientSettings says that on an +// update the settings left empty or nil are not sent. Each case runs against both. The fake must keep +// the current value, and the adapter must leave the field out of the request. +func TestClientUpdateContract_ASettingLeftEmptyIsNotSent(t *testing.T) { + t.Parallel() + + current := portals.ClientSettings{ + Name: "Acme", + Callbacks: []string{"https://acme/cb"}, + AllowedLogoutURLs: []string{"https://acme/out"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + } + + tests := []struct { + name string + update portals.ClientSettings + // Field is the field of the request. Read returns the same setting from an application. + field string + read func(portals.ClientSettings) any + // Want is what the application has after the update. Sent says if the request carries it. + want any + sent bool + }{ + { + name: "grant types: an empty list", + update: portals.ClientSettings{GrantTypes: []string{}}, + field: "grant_types", + read: func(settings portals.ClientSettings) any { return settings.GrantTypes }, + want: current.GrantTypes, + }, + { + name: "grant types: nil", + update: portals.ClientSettings{Name: "Renamed"}, + field: "grant_types", + read: func(settings portals.ClientSettings) any { return settings.GrantTypes }, + want: current.GrantTypes, + }, + { + name: "callbacks: an empty list", + update: portals.ClientSettings{Callbacks: []string{}}, + field: "callbacks", + read: func(settings portals.ClientSettings) any { return settings.Callbacks }, + want: current.Callbacks, + }, + { + name: "logout URLs: an empty list", + update: portals.ClientSettings{AllowedLogoutURLs: []string{}}, + field: "allowed_logout_urls", + read: func(settings portals.ClientSettings) any { return settings.AllowedLogoutURLs }, + want: current.AllowedLogoutURLs, + }, + { + name: "name: an empty string", + update: portals.ClientSettings{Callbacks: []string{"https://acme/other"}}, + field: "name", + read: func(settings portals.ClientSettings) any { return settings.Name }, + want: current.Name, + }, + { + name: "callbacks: a list replaces the current one", + update: portals.ClientSettings{Callbacks: []string{"https://acme/other"}}, + field: "callbacks", + read: func(settings portals.ClientSettings) any { return settings.Callbacks }, + want: []string{"https://acme/other"}, + sent: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name+" (in-memory tenant)", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.AddClient(portals.Client{ID: "cli_1", ClientSettings: current}) + + require.NoError(t, tenant.UpdateClient(context.Background(), "cli_1", tt.update)) + + client, err := tenant.ReadClient(context.Background(), "cli_1") + require.NoError(t, err) + assert.Equal(t, tt.want, tt.read(client.ClientSettings)) + }) + + t.Run(tt.name+" (adapter over the SDK)", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPatch, "clients/cli_1", 200, `{"client_id":"cli_1"}`) + + require.NoError(t, tenant.UpdateClient(context.Background(), "cli_1", tt.update)) + + body := server.received(http.MethodPatch, "clients/cli_1")[0].json(t) + value, sent := body[tt.field] + assert.Equal(t, tt.sent, sent, "the request is %v", body) + + if tt.sent { + wantJSON, err := json.Marshal(tt.want) + require.NoError(t, err) + gotJSON, err := json.Marshal(value) + require.NoError(t, err) + assert.JSONEq(t, string(wantJSON), string(gotJSON)) + } + }) + } +} diff --git a/internal/cli/universal_portals_dryrun.go b/internal/cli/universal_portals_dryrun.go new file mode 100644 index 000000000..58c3fce91 --- /dev/null +++ b/internal/cli/universal_portals_dryrun.go @@ -0,0 +1,283 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + "sync" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/portals" +) + +// dryRunClientSecret stands in for every client secret in the plan. +const dryRunClientSecret = "" + +// plannedRequest is a request a real run would send. +type plannedRequest struct { + Method string `json:"method"` + Path string `json:"path"` + // Body has every client secret replaced. + Body any `json:"body,omitempty"` +} + +// portalsDryRun is an HTTP transport that sends the reads to the tenant and keeps every other +// request instead of sending it. Setup runs as it does for real, so the created, reused and updated +// decisions and the payloads all come from the same code. The IDs of the resources a real run would +// create are placeholders, and a read about one of them finds nothing, because that resource does +// not exist yet. +type portalsDryRun struct { + base http.RoundTripper + + mu sync.Mutex + requests []plannedRequest + counter int + // The placeholders given to the resources that a real run would create. + placeholders []string + // The connections enabled for an organization that would be created, from the requests kept. + enabledConnections map[string][]string +} + +var _ http.RoundTripper = (*portalsDryRun)(nil) + +func newPortalsDryRun(base http.RoundTripper) *portalsDryRun { + return &portalsDryRun{base: base, enabledConnections: map[string][]string{}} +} + +// planned returns the requests that were kept, in order. +func (d *portalsDryRun) planned() []plannedRequest { + d.mu.Lock() + defer d.mu.Unlock() + + return slices.Clone(d.requests) +} + +// RoundTrip implements http.RoundTripper. +func (d *portalsDryRun) RoundTrip(request *http.Request) (*http.Response, error) { + body, err := readAndRestoreBody(request) + if err != nil { + return nil, fmt.Errorf("read the request to %s: %w", request.URL.Path, err) + } + + if request.Method == http.MethodGet || request.Method == http.MethodHead { + return d.read(request) + } + + return d.keep(request, body), nil +} + +func readAndRestoreBody(request *http.Request) ([]byte, error) { + if request.Body == nil { + return nil, nil + } + + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + + _ = request.Body.Close() + request.Body = io.NopCloser(bytes.NewReader(body)) + + return body, nil +} + +// apiPath is the path of a request below the Management API. +func apiPath(request *http.Request) string { + path := request.URL.Path + + if index := strings.Index(path, "/api/v2/"); index >= 0 { + return path[index+len("/api/v2/"):] + } + + return strings.TrimPrefix(path, "/") +} + +// read sends a read to the tenant, unless it is about a resource that does not exist yet. +func (d *portalsDryRun) read(request *http.Request) (*http.Response, error) { + path := apiPath(request) + + d.mu.Lock() + about := d.placeholderIn(path + "?" + request.URL.RawQuery) + connections := d.enabledConnections[about] + d.mu.Unlock() + + if about == "" { + return d.base.RoundTrip(request) + } + + // The connections of an organization that would be created are the ones the plan enables on it. + if strings.HasSuffix(path, "/enabled_connections") && len(connections) > 0 { + entries := make([]map[string]string, 0, len(connections)) + for _, id := range connections { + entries = append(entries, map[string]string{"connection_id": id}) + } + + return jsonResponse(request, map[string]any{"enabled_connections": entries, "total": len(entries), "limit": 100}) + } + + // An empty object reads as an empty list for every kind of listing. + return jsonResponse(request, map[string]any{}) +} + +// placeholderIn returns the placeholder the text is about, or "". +func (d *portalsDryRun) placeholderIn(text string) string { + decoded, err := url.QueryUnescape(text) + if err != nil { + decoded = text + } + + for _, placeholder := range d.placeholders { + if strings.Contains(decoded, placeholder) { + return placeholder + } + } + + return "" +} + +// keep records a request that would change the tenant and answers it the way the API would. +func (d *portalsDryRun) keep(request *http.Request, body []byte) *http.Response { + var decoded any + if len(body) > 0 { + if err := json.Unmarshal(body, &decoded); err != nil { + decoded = string(body) + } + } + + path := apiPath(request) + + d.mu.Lock() + defer d.mu.Unlock() + + d.requests = append(d.requests, plannedRequest{Method: request.Method, Path: path, Body: redactSecrets(decoded)}) + + if request.Method != http.MethodPost { + response, _ := jsonResponse(request, map[string]any{}) + + return response + } + + d.counter++ + id := fmt.Sprintf("", strings.SplitN(path, "/", 2)[0], d.counter) + d.placeholders = append(d.placeholders, id) + + if segments := strings.Split(path, "/"); len(segments) == 3 && segments[0] == "organizations" && segments[2] == "enabled_connections" { + if fields, isObject := decoded.(map[string]any); isObject { + connection, _ := fields["connection_id"].(string) + d.enabledConnections[segments[1]] = append(d.enabledConnections[segments[1]], connection) + } + } + + response, _ := jsonResponse(request, createdResponse(path, decoded, id)) + + return response +} + +// createdResponse is the part of the answer to a create that setup reads back: the ID, and for an +// application the secret. +func createdResponse(path string, body any, id string) map[string]any { + fields, _ := body.(map[string]any) + created := make(map[string]any, len(fields)+2) + + for key, value := range fields { + created[key] = value + } + + switch path { + case "clients": + created["client_id"] = id + created["client_secret"] = dryRunClientSecret + case "forms/import": + return map[string]any{"form": map[string]any{"id": id, "nodes": []any{}}, "flows": []any{}} + default: + created["id"] = id + } + + return created +} + +// redactSecrets copies the value and replaces every client secret, so a secret read from the tenant +// for a reused application can never reach the plan. +func redactSecrets(value any) any { + switch typed := value.(type) { + case []any: + redacted := make([]any, 0, len(typed)) + for _, item := range typed { + redacted = append(redacted, redactSecrets(item)) + } + + return redacted + case map[string]any: + redacted := make(map[string]any, len(typed)) + + for key, item := range typed { + if key == "client_secret" { + redacted[key] = dryRunClientSecret + continue + } + + redacted[key] = redactSecrets(item) + } + + return redacted + default: + return value + } +} + +func jsonResponse(request *http.Request, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("encode the answer to %s: %w", request.URL.Path, err) + } + + return &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(data)), + ContentLength: int64(len(data)), + Request: request, + }, nil +} + +// dryRunTenant builds the tenant of a dry run: its clients read from the tenant and keep the writes. +// It is built from what the clients of the CLI were built with. +func dryRunTenant(access apiAccess) (*portalsTenant, *portalsDryRun, error) { + return dryRunTenantOver(customClientWithRetries(access.invokerMetadata).Transport, access) +} + +// dryRunTenantOver is dryRunTenant with the transport that sends the reads. +func dryRunTenantOver(base http.RoundTripper, access apiAccess) (*portalsTenant, *portalsDryRun, error) { + plan := newPortalsDryRun(base) + httpClient := &http.Client{Transport: plan} + + v1, err := initializeManagementClientWith(access.domain, access.accessToken, httpClient) + if err != nil { + return nil, nil, fmt.Errorf("could not prepare the dry run: %w", err) + } + + v3, err := initializeManagementClientV3With(access.domain, access.accessToken, httpClient) + if err != nil { + return nil, nil, fmt.Errorf("could not prepare the dry run: %w", err) + } + + return newPortalsTenant(auth0.NewAPI(v1), auth0.NewAPIV3(v3)), plan, nil +} + +// dryRunResult is the result of setup for a dry run: the result of a real run, and the requests it +// would send in the order it would send them. +type dryRunResult struct { + DryRun bool `json:"dryRun"` + portals.SetupResult + Requests []plannedRequest `json:"requests"` +} diff --git a/internal/cli/universal_portals_dryrun_test.go b/internal/cli/universal_portals_dryrun_test.go new file mode 100644 index 000000000..3d0a6508f --- /dev/null +++ b/internal/cli/universal_portals_dryrun_test.go @@ -0,0 +1,495 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +// stubTransport answers every request with a fixed body and remembers what reached it. +type stubTransport struct { + mu sync.Mutex + received []string +} + +func (s *stubTransport) RoundTrip(request *http.Request) (*http.Response, error) { + s.mu.Lock() + s.received = append(s.received, request.Method+" "+request.URL.Path) + s.mu.Unlock() + + return jsonResponse(request, map[string]any{"from": "the tenant"}) +} + +func (s *stubTransport) reached() []string { + s.mu.Lock() + defer s.mu.Unlock() + + return append([]string(nil), s.received...) +} + +func send(t *testing.T, transport http.RoundTripper, method, path, body string) map[string]any { + t.Helper() + + request, err := http.NewRequestWithContext(context.Background(), method, "https://acme.us.auth0.com/api/v2/"+path, strings.NewReader(body)) + require.NoError(t, err) + + response, err := transport.RoundTrip(request) + require.NoError(t, err) + + defer func() { _ = response.Body.Close() }() + + data, err := io.ReadAll(response.Body) + require.NoError(t, err) + + var answer map[string]any + require.NoError(t, json.Unmarshal(data, &answer), string(data)) + + return answer +} + +func TestPortalsDryRun(t *testing.T) { + t.Parallel() + + t.Run("a read goes to the tenant", func(t *testing.T) { + t.Parallel() + + tenant := &stubTransport{} + plan := newPortalsDryRun(tenant) + + answer := send(t, plan, http.MethodGet, "clients?per_page=1", "") + + assert.Equal(t, map[string]any{"from": "the tenant"}, answer) + assert.Equal(t, []string{"GET /api/v2/clients"}, tenant.reached()) + assert.Empty(t, plan.planned()) + }) + + t.Run("a write is kept and never sent", func(t *testing.T) { + t.Parallel() + + tenant := &stubTransport{} + plan := newPortalsDryRun(tenant) + + send(t, plan, http.MethodPost, "resource-servers", `{"name":"API","identifier":"https://acme/me/"}`) + send(t, plan, http.MethodPatch, "client-grants/cgr_1", `{"scope":["a"]}`) + send(t, plan, http.MethodDelete, "client-grants/cgr_2", "") + + assert.Empty(t, tenant.reached()) + assert.Equal(t, []plannedRequest{ + {Method: "POST", Path: "resource-servers", Body: map[string]any{"name": "API", "identifier": "https://acme/me/"}}, + {Method: "PATCH", Path: "client-grants/cgr_1", Body: map[string]any{"scope": []any{"a"}}}, + {Method: "DELETE", Path: "client-grants/cgr_2"}, + }, plan.planned()) + }) + + t.Run("a create is answered with a placeholder ID, numbered in the order of the creates", func(t *testing.T) { + t.Parallel() + + plan := newPortalsDryRun(&stubTransport{}) + + first := send(t, plan, http.MethodPost, "resource-servers", `{"name":"API"}`) + second := send(t, plan, http.MethodPost, "client-grants", `{"audience":"https://acme/me/"}`) + update := send(t, plan, http.MethodPatch, "client-grants/x", `{"scope":["a"]}`) + + assert.Equal(t, map[string]any{"name": "API", "id": ""}, first) + assert.Equal(t, map[string]any{"audience": "https://acme/me/", "id": ""}, second) + assert.Equal(t, map[string]any{}, update) + }) + + t.Run("a new application comes with a placeholder secret", func(t *testing.T) { + t.Parallel() + + plan := newPortalsDryRun(&stubTransport{}) + + answer := send(t, plan, http.MethodPost, "clients", `{"name":"Acme"}`) + + assert.Equal(t, map[string]any{"name": "Acme", "client_id": "", "client_secret": ""}, answer) + }) + + t.Run("an imported form comes with a placeholder ID and no flow", func(t *testing.T) { + t.Parallel() + + plan := newPortalsDryRun(&stubTransport{}) + + answer := send(t, plan, http.MethodPost, "forms/import", `{"form":{"name":"Profile"}}`) + + assert.Equal(t, map[string]any{ + "form": map[string]any{"id": "", "nodes": []any{}}, + "flows": []any{}, + }, answer) + }) + + t.Run("a read about a resource that does not exist yet finds nothing, and does not reach the tenant", func(t *testing.T) { + t.Parallel() + + tenant := &stubTransport{} + plan := newPortalsDryRun(tenant) + created := send(t, plan, http.MethodPost, "clients", `{"name":"Acme"}`) + + path := "client-grants?client_id=" + url.QueryEscape(created["client_id"].(string)) + answer := send(t, plan, http.MethodGet, path, "") + + assert.Equal(t, map[string]any{}, answer) + assert.Empty(t, tenant.reached()) + }) + + t.Run("a secret never reaches the plan, wherever it is", func(t *testing.T) { + t.Parallel() + + plan := newPortalsDryRun(&stubTransport{}) + + send(t, plan, http.MethodPost, "portals", `{"slug":"acme","client":{"client_id":"cli_1","client_secret":"real-secret"},"list":[{"client_secret":"another"}]}`) + + encoded, err := json.Marshal(plan.planned()) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "real-secret") + assert.NotContains(t, string(encoded), "another") + + body := plan.planned()[0].Body.(map[string]any) + assert.Equal(t, "", body["client"].(map[string]any)["client_secret"]) + assert.Equal(t, "", body["list"].([]any)[0].(map[string]any)["client_secret"]) + assert.Equal(t, "cli_1", body["client"].(map[string]any)["client_id"], "the rest is kept") + }) + + t.Run("the connections enabled in the plan are the ones the organization has", func(t *testing.T) { + t.Parallel() + + plan := newPortalsDryRun(&stubTransport{}) + organization := send(t, plan, http.MethodPost, "organizations", `{"name":"acme"}`)["id"].(string) + + send(t, plan, http.MethodPost, "organizations/"+organization+"/enabled_connections", `{"connection_id":"con_db"}`) + answer := send(t, plan, http.MethodGet, "organizations/"+organization+"/enabled_connections", "") + + assert.Equal(t, []any{map[string]any{"connection_id": "con_db"}}, answer["enabled_connections"]) + }) +} + +// newDryRunServer starts a local TLS server for a tenant with nothing in it, and returns the access to +// it. A dry run and a run of the checks of the permissions both go to this server. +func newDryRunServer(t *testing.T) (*portalsServer, *httptest.Server, apiAccess) { + t.Helper() + + server := &portalsServer{t: t, routes: map[string]func(receivedRequest) (int, string){}} + tls := httptest.NewTLSServer(http.HandlerFunc(server.serve)) + t.Cleanup(tls.Close) + + host := strings.TrimPrefix(tls.URL, "https://") + + empty := map[string]string{ + "resource-servers": `{"resource_servers":[],"total":0}`, + "clients": `{"clients":[],"total":0}`, + "client-grants": `{"client_grants":[]}`, + "flows/vault/connections": `{"connections":[]}`, + "forms": `{"forms":[],"total":0}`, + "portals": `{"portals":[]}`, + } + for path, body := range empty { + server.reply(http.MethodGet, path, 200, body) + } + + server.reply(http.MethodGet, "custom-domains/default", 404, `{"statusCode":404,"message":"Not Found"}`) + + // The checks of the permissions send writes with an empty body, which the API rejects. + for _, path := range []string{"resource-servers", "clients", "client-grants", "flows/vault/connections", "forms/import", "portals"} { + server.reply(http.MethodPost, path, 400, `{"statusCode":400,"message":"Payload validation error"}`) + } + + server.reply(http.MethodPatch, "clients/cli_preflight0000000", 404, `{"statusCode":404,"message":"The client does not exist"}`) + server.reply(http.MethodPatch, "client-grants/cgr_preflight0000000", 404, `{"statusCode":404,"message":"The grant does not exist"}`) + server.reply(http.MethodPatch, "flows/vault/connections/ac_preflight0000000000000", 404, `{"statusCode":404,"message":"Connection not found"}`) + + return server, tls, apiAccess{domain: host, accessToken: "token"} +} + +// newTenantsOver returns the tenant of a dry run, the plan, and the tenant that sends the checks. +func newTenantsOver(t *testing.T, tls *httptest.Server, access apiAccess) (*portalsTenant, *portalsDryRun, *portalsTenant) { + t.Helper() + + dry, plan, err := dryRunTenantOver(tls.Client().Transport, access) + require.NoError(t, err) + + v1, err := initializeManagementClientWith(access.domain, access.accessToken, tls.Client()) + require.NoError(t, err) + v3, err := initializeManagementClientV3With(access.domain, access.accessToken, tls.Client()) + require.NoError(t, err) + + return dry, plan, newPortalsTenant(auth0.NewAPI(v1), auth0.NewAPIV3(v3)) +} + +func dryRunDeps(dry portals.API, prober portals.Prober) (portals.SetupDeps, *portalstest.Reporter) { + reporter := &portalstest.Reporter{} + + return portals.SetupDeps{ + API: dry, + Prober: prober, + Reporter: reporter, + Prompter: &portalstest.Prompter{}, + TenantDomain: "acme.us.auth0.com", + }, reporter +} + +// The tests below build the clients the way the CLI does, and the SDK changes a map that all its +// clients share when one is built, so they do not run in parallel with each other. +func TestPortalsDryRun_SetupOfAConsumerPortal(t *testing.T) { + server, tls, access := newDryRunServer(t) + dry, plan, checker := newTenantsOver(t, tls, access) + deps, _ := dryRunDeps(dry, checker) + + result, err := portals.Setup(context.Background(), consumerOptionsForDryRun(), deps) + + require.NoError(t, err) + + t.Run("it reports what a real run would create", func(t *testing.T) { + assert.Equal(t, portals.StatusCreated, result.Portal.Status) + assert.Equal(t, portals.StatusCreated, result.Application.Status) + + for _, resource := range result.Resources { + assert.Equal(t, portals.StatusCreated, resource.Status, resource.Type) + } + }) + + t.Run("it plans every write, in the order of a real run", func(t *testing.T) { + var steps []string + for _, request := range plan.planned() { + steps = append(steps, request.Method+" "+request.Path) + } + + assert.Equal(t, []string{ + "POST resource-servers", + "POST clients", + "POST client-grants", + "POST clients", + "POST client-grants", + "POST flows/vault/connections", + "POST forms/import", + "POST forms/import", + "POST forms/import", + "POST portals", + }, steps) + }) + + t.Run("the portal is planned with the placeholder of the application and its placeholder secret", func(t *testing.T) { + portal := plan.planned()[len(plan.planned())-1] + client := portal.Body.(map[string]any)["client"].(map[string]any) + + assert.Equal(t, "", client["client_id"]) + assert.Equal(t, "", client["client_secret"]) + assert.Equal(t, "", result.Application.ClientID) + }) + + t.Run("nothing reached the tenant but the reads and the checks", func(t *testing.T) { + // A write that was sent would have been a second request to the route, and the only one + // the tenant knows is the check, which carries an empty body. + for _, path := range []string{"resource-servers", "clients", "client-grants", "flows/vault/connections", "forms/import", "portals"} { + posts := server.received(http.MethodPost, path) + require.Len(t, posts, 1, "POST "+path) + assert.JSONEq(t, `{}`, posts[0].Body, "POST "+path) + } + + assert.Len(t, server.received(http.MethodGet, "client-grants"), 1, "only the check, because the application does not exist yet") + }) +} + +func consumerOptionsForDryRun() portals.SetupOptions { + return portals.SetupOptions{Template: portals.TemplateConsumer, Name: "Acme Account", Slug: "acme-account"} +} + +func TestPortalsDryRun_ASecretReadFromTheTenantNeverReachesThePlan(t *testing.T) { + server, tls, access := newDryRunServer(t) + + // The application of an earlier run exists, and its secret is read to plan the portal. + server.reply(http.MethodGet, "clients", 200, `{"clients":[ + {"client_id":"cli_existing","name":"Acme Account (Universal Portals)","client_metadata":{"portals_setup_slug":"acme-account"}, + "session_transfer":{"allowed_authentication_methods":["query"]}}],"start":0,"limit":100,"total":1}`) + server.reply(http.MethodGet, "clients/cli_existing", 200, `{"client_id":"cli_existing","client_secret":"real-secret-xyz"}`) + + dry, plan, checker := newTenantsOver(t, tls, access) + deps, reporter := dryRunDeps(dry, checker) + + result, err := portals.Setup(context.Background(), consumerOptionsForDryRun(), deps) + + require.NoError(t, err) + assert.Equal(t, portals.StatusReused, result.Application.Status) + + encoded, err := json.Marshal(plan.planned()) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "real-secret-xyz", "the plan never carries the real secret") + + for _, line := range reporter.Lines() { + assert.NotContains(t, line, "real-secret-xyz") + } + + var steps []string + for _, request := range plan.planned() { + steps = append(steps, request.Method+" "+request.Path) + } + + assert.Contains(t, steps, "PATCH clients/cli_existing", "the settings of the application are brought back") + + portal := plan.planned()[len(plan.planned())-1] + assert.Equal(t, "", portal.Body.(map[string]any)["client"].(map[string]any)["client_secret"]) +} + +func TestReportPortalsPlan(t *testing.T) { + t.Parallel() + + result := portals.SetupResult{Template: portals.TemplateConsumer, Portal: portals.PortalResult{ID: "", Status: portals.StatusCreated}} + planned := []plannedRequest{{Method: "POST", Path: "portals", Body: map[string]any{"slug": "acme"}}} + + t.Run("a person gets a summary and no JSON", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(false) + terminal := newPortalsTerminal(&run.messages, plainPortalsStyle(), false, false) + + reportPortalsPlan(run.cli, terminal, result, planned) + + assert.Contains(t, run.messages.String(), "Dry run complete. 1 request(s) would change the tenant.") + assert.Contains(t, run.messages.String(), "Run the same command without --dry-run to set up the portal.") + assert.Empty(t, run.results.String()) + }) + + t.Run("JSON is the result and the plan", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + terminal := newPortalsTerminal(&run.messages, plainPortalsStyle(), true, false) + + reportPortalsPlan(run.cli, terminal, result, planned) + + var printed map[string]any + require.NoError(t, json.Unmarshal(run.results.Bytes(), &printed)) + assert.Equal(t, true, printed["dryRun"]) + assert.Equal(t, "consumer", printed["template"]) + assert.Equal(t, []any{map[string]any{"method": "POST", "path": "portals", "body": map[string]any{"slug": "acme"}}}, printed["requests"]) + assert.Empty(t, run.messages.String()) + }) + + t.Run("a plan with no request is an empty list, not null", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + terminal := newPortalsTerminal(&run.messages, plainPortalsStyle(), true, false) + + reportPortalsPlan(run.cli, terminal, result, nil) + + assert.Contains(t, run.results.String(), `"requests": []`) + }) +} + +// The business template is the one that creates an organization, enables a connection for it and adds +// a member. The organization the plan makes does not exist, so the reads that follow its creation are +// answered from the plan. +func TestPortalsDryRun_SetupOfABusinessPortalThatCreatesAnOrganization(t *testing.T) { + server, tls, access := newDryRunServer(t) + + server.reply(http.MethodGet, "users", 200, `{"start":0,"limit":50,"length":1,"total":1,"users":[{"user_id":"auth0|a","email":"jane@acme.com","name":"jane"}]}`) + server.reply(http.MethodGet, "users/auth0%7Ca/organizations", 200, `{"organizations":[],"total":0}`) + server.reply(http.MethodGet, "organizations", 200, `{"organizations":[],"total":0}`) + server.reply(http.MethodGet, "roles", 200, `{"roles":[],"total":0}`) + server.reply(http.MethodGet, "connections", 200, `{"connections":[{"id":"con_db","name":"Username-Password-Authentication","strategy":"auth0"}]}`) + + // The checks of the permissions that only a business portal needs. + server.reply(http.MethodPost, "organizations", 400, `{"statusCode":400,"message":"Payload validation error"}`) + server.reply(http.MethodPost, "roles", 400, `{"statusCode":400,"message":"Payload validation error"}`) + + for _, path := range []string{ + "organizations/org_preflight0000000/enabled_connections", + "organizations/org_preflight0000000/members", + "organizations/org_preflight0000000/members/auth0%7Cpreflight0000000000/roles", + "roles/rol_preflight0000000/permissions", + } { + server.reply(http.MethodPost, path, 404, `{"statusCode":404,"message":"Not found"}`) + } + + dry, plan, checker := newTenantsOver(t, tls, access) + deps, _ := dryRunDeps(dry, checker) + deps.Interactive = true + + // The user belongs to no organization, so setup offers to create one, and then a role. + asks := []string{"Acme Admins", "acme-admins"} + confirms := []bool{true, false} + + deps.Prompter = &portalstest.Prompter{ + AskFunc: func(question, _, _ string) (string, error) { + if len(asks) == 0 { + return "", errors.New("unexpected question: " + question) + } + + answer := asks[0] + asks = asks[1:] + + return answer, nil + }, + ChooseFunc: func(question string, options []portals.Choice) (int, error) { + for index, option := range options { + if strings.Contains(option.Label, "None of these") { + return index, nil + } + } + + return 0, errors.New("unexpected choice: " + question) + }, + ConfirmFunc: func(question string, _ bool) (bool, error) { + if len(confirms) == 0 { + return false, errors.New("unexpected confirmation: " + question) + } + + answer := confirms[0] + confirms = confirms[1:] + + return answer, nil + }, + } + + result, err := portals.Setup(context.Background(), portals.SetupOptions{ + Template: portals.TemplateBusiness, Name: "Acme Admin", Slug: "acme-admin", UserQuery: "jane@acme.com", + }, deps) + + require.NoError(t, err) + assert.Equal(t, "", result.Organization.ID) + + var steps []string + for _, request := range plan.planned() { + steps = append(steps, request.Method+" "+request.Path) + } + + assert.Equal(t, []string{ + "POST organizations", + "POST organizations//enabled_connections", + "POST organizations//members", + "POST resource-servers", + "POST resource-servers", + "POST clients", + "POST client-grants", + "POST client-grants", + "POST clients", + "POST client-grants", + "POST flows/vault/connections", + "POST forms/import", + "POST forms/import", + "POST forms/import", + "POST portals", + }, steps) + + // A write that was sent would be a second request to the route. The one the tenant got is the check + // of the permissions, which carries an empty body. + for _, path := range []string{"organizations", "roles"} { + posts := server.received(http.MethodPost, path) + require.Len(t, posts, 1, "POST "+path) + assert.JSONEq(t, `{}`, posts[0].Body, "POST "+path) + } +} diff --git a/internal/cli/universal_portals_errors.go b/internal/cli/universal_portals_errors.go new file mode 100644 index 000000000..0b6704e7b --- /dev/null +++ b/internal/cli/universal_portals_errors.go @@ -0,0 +1,110 @@ +package cli + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/auth0/auth0-cli/internal/portals" +) + +// portalsResumeHint tells how to finish a setup that stopped half way. +const portalsResumeHint = "Run the same command again to resume. Existing resources are reused." + +// portalsFixError carries the steps that resolve an error. They are shown after the message on a +// terminal, through the hint block of the root command, and listed in the details of the JSON error. +type portalsFixError struct { + err error + fixes []string +} + +func (e portalsFixError) Error() string { return e.err.Error() } + +func (e portalsFixError) Unwrap() error { return e.err } + +func (e portalsFixError) ErrorDetails() json.RawMessage { + raw, err := json.Marshal(struct { + Fixes []string `json:"fixes"` + }{Fixes: e.fixes}) + if err != nil { + return nil + } + + return raw +} + +// portalsCommandError gives an error of setup or audit the class and the reason the CLI reports, and +// the steps that resolve it. When wrote is true, a request changed the tenant before the error, so a +// rerun resumes. +func portalsCommandError(err error, wrote bool) error { + if err == nil { + return nil + } + + classified := classifyPortalsError(err) + fixes := portals.FixesOf(err) + + // A slug taken while setup runs is not resumed by a rerun with the same name. + if _, race := errors.AsType[*portals.SlugRaceError](err); wrote && !race { + fixes = append(fixes, portalsResumeHint) + } + + if len(fixes) == 0 { + return classified + } + + return portalsFixError{ + err: apiResponseHintError{err: classified, hint: strings.Join(fixes, "\n")}, + fixes: fixes, + } +} + +func classifyPortalsError(err error) error { + if _, ok := errors.AsType[*portals.UsageError](err); ok { + return usageError{err: err, reason: "invalid_input"} + } + + if _, ok := errors.AsType[*portals.BusinessAccessError](err); ok { + return validationError{err: err, reason: "business_access"} + } + + if _, ok := errors.AsType[*portals.NameInUseError](err); ok { + return validationError{err: err, reason: "name_in_use"} + } + + if _, ok := errors.AsType[*portals.SlugTakenError](err); ok { + return validationError{err: err, reason: "slug_taken"} + } + + if _, ok := errors.AsType[*portals.SlugRaceError](err); ok { + return validationError{err: err, reason: "slug_race"} + } + + if _, ok := errors.AsType[*portals.MissingScopesError](err); ok { + return authError{err: err, reason: "missing_scopes"} + } + + if _, ok := errors.AsType[*portals.ClientSecretUnreadableError](err); ok { + return authError{err: err, reason: "missing_scopes"} + } + + if _, ok := errors.AsType[*portals.PermissionCheckError](err); ok { + return authError{err: err, reason: "permission_check_failed"} + } + + if _, ok := errors.AsType[*portals.FeatureNotEnabledError](err); ok { + return authError{err: err, reason: "feature_not_enabled"} + } + + // A request refused for a missing scope is fixed the same way whichever command made it. + if _, isGap := portals.ParseScopeGap("", err); isGap { + return authError{err: err, reason: "insufficient_scope"} + } + + if portals.IsStatus(err, http.StatusForbidden) { + return authError{err: err, reason: "insufficient_scope"} + } + + return err +} diff --git a/internal/cli/universal_portals_prompter.go b/internal/cli/universal_portals_prompter.go new file mode 100644 index 000000000..cbacbfe28 --- /dev/null +++ b/internal/cli/universal_portals_prompter.go @@ -0,0 +1,141 @@ +package cli + +import ( + "errors" + "fmt" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/core" + "github.com/AlecAivazis/survey/v2/terminal" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/prompt" +) + +// The most options a list shows at once. +const portalsMaxVisibleOptions = 15 + +// portalsPrompter asks the questions of setup and audit on the terminal. A Ctrl+C is not an exit +// here: it comes back as portals.ErrCancelled, so the command can say what was and was not done. +type portalsPrompter struct{} + +var _ portals.Prompter = portalsPrompter{} + +const answerKey = "answer" + +// ask shows one question and returns the answer. +func (portalsPrompter) ask(question survey.Prompt) (any, error) { + response := map[string]any{} + + if err := prompt.AskOne(&survey.Question{Name: answerKey, Prompt: question}, &response); err != nil { + if errors.Is(err, terminal.InterruptErr) { + return nil, portals.ErrCancelled + } + + return nil, fmt.Errorf("could not read the answer: %w", err) + } + + return response[answerKey], nil +} + +// Ask implements portals.Prompter. +func (p portalsPrompter) Ask(question, defaultValue, example string) (string, error) { + message := question + if defaultValue == "" && example != "" { + message += " (for example " + example + ")" + } + + answer, err := p.ask(&survey.Input{Message: message, Default: defaultValue}) + if err != nil { + return "", err + } + + text, _ := answer.(string) + if text = strings.TrimSpace(text); text != "" { + return text, nil + } + + return defaultValue, nil +} + +// Confirm implements portals.Prompter. +func (p portalsPrompter) Confirm(question string, defaultValue bool) (bool, error) { + answer, err := p.ask(&survey.Confirm{Message: question, Default: defaultValue}) + if err != nil { + return false, err + } + + confirmed, _ := answer.(bool) + + return confirmed, nil +} + +// Choose implements portals.Prompter. +func (p portalsPrompter) Choose(question string, options []portals.Choice) (int, error) { + labels := make([]string, 0, len(options)) + for _, option := range options { + labels = append(labels, optionLabel(option.Label, option.Hint)) + } + + answer, err := p.ask(&survey.Select{ + Message: question, + Options: labels, + PageSize: min(len(labels), portalsMaxVisibleOptions), + }) + if err != nil { + return 0, err + } + + picked, ok := answer.(core.OptionAnswer) + if !ok { + return 0, fmt.Errorf("unexpected answer %v to %q", answer, question) + } + + return picked.Index, nil +} + +// Checklist implements portals.Prompter. +func (p portalsPrompter) Checklist(question string, items []portals.ChecklistItem) ([]int, error) { + labels := make([]string, 0, len(items)) + checked := make([]int, 0, len(items)) + + for index, item := range items { + labels = append(labels, optionLabel(item.Label, item.Hint)) + + if item.Checked { + checked = append(checked, index) + } + } + + answer, err := p.ask(&survey.MultiSelect{ + Message: question, + Options: labels, + Default: checked, + PageSize: min(len(labels), portalsMaxVisibleOptions), + }) + if err != nil { + return nil, err + } + + picked, ok := answer.([]core.OptionAnswer) + if !ok { + return nil, fmt.Errorf("unexpected answer %v to %q", answer, question) + } + + indexes := make([]int, 0, len(picked)) + for _, option := range picked { + indexes = append(indexes, option.Index) + } + + return indexes, nil +} + +// optionLabel is the text of an option, with its hint after it. +func optionLabel(label, hint string) string { + if hint == "" { + return label + } + + return label + " (" + hint + ")" +} diff --git a/internal/cli/universal_portals_prompter_test.go b/internal/cli/universal_portals_prompter_test.go new file mode 100644 index 000000000..dcd03f25e --- /dev/null +++ b/internal/cli/universal_portals_prompter_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOptionLabel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + label string + hint string + want string + }{ + {name: "without a hint", label: "Consumer portal", want: "Consumer portal"}, + {name: "with a hint", label: "Fix all errors", hint: "recommended", want: "Fix all errors (recommended)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, optionLabel(tt.label, tt.hint)) + }) + } +} diff --git a/internal/cli/universal_portals_tenant.go b/internal/cli/universal_portals_tenant.go new file mode 100644 index 000000000..f496512a2 --- /dev/null +++ b/internal/cli/universal_portals_tenant.go @@ -0,0 +1,177 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync/atomic" + + "github.com/auth0/go-auth0/management" + "github.com/auth0/go-auth0/v3/management/core" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/portals" +) + +const ( + // The number of items asked for in each page. + portalsPageSize = 100 + // The number of pages after which a listing that never ends is stopped. + portalsMaxPages = 100 +) + +// portalsTenant implements portals.API on the typed Management API clients of the CLI. Only the +// requests no typed client can send go through the raw HTTP client. +type portalsTenant struct { + api *auth0.API + apiv3 *auth0.APIV3 + // Set when a request that changes the tenant succeeded. + wrote atomic.Bool +} + +var _ portals.API = (*portalsTenant)(nil) + +func newPortalsTenant(api *auth0.API, apiv3 *auth0.APIV3) *portalsTenant { + return &portalsTenant{api: api, apiv3: apiv3} +} + +// written records that a request changed the tenant, when it succeeded, and returns its error. +func (t *portalsTenant) written(err error) error { + if err == nil { + t.wrote.Store(true) + } + + return err +} + +// hasWritten reports whether any request that changes the tenant succeeded. +func (t *portalsTenant) hasWritten() bool { + return t.wrote.Load() +} + +// asPortalsError turns a failure of the SDK into a *portals.APIError, so the portals package reads +// the status and the error code the same way whichever client made the request. Any other error is +// returned as it is. +func asPortalsError(err error) error { + if err == nil { + return nil + } + + if _, ok := errors.AsType[*portals.APIError](err); ok { + return err + } + + if v1, ok := errors.AsType[management.Error](err); ok { + // The text of the error is " : ", and the reason has no colon. + _, message, _ := strings.Cut(v1.Error(), ": ") + + return &portals.APIError{Status: v1.Status(), Code: v1.Code(), Message: message, Err: v1} + } + + if v3, ok := errors.AsType[*core.APIError](err); ok { + _, body, _ := strings.Cut(v3.Error(), ": ") + + apiErr := apiErrorFromBody(v3.StatusCode, []byte(body)) + apiErr.Err = v3 + + return apiErr + } + + return err +} + +// apiErrorFromBody builds the error of a response from its status and body. +func apiErrorFromBody(status int, body []byte) *portals.APIError { + apiErr := &portals.APIError{ + Status: status, + Message: strings.TrimSpace(string(body)), + Err: core.NewAPIError(status, nil, errors.New(strings.TrimSpace(string(body)))), + } + + var parsed struct { + ErrorCode string `json:"errorCode"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &parsed); err == nil && (parsed.ErrorCode != "" || parsed.Message != "") { + apiErr.Code = parsed.ErrorCode + apiErr.Message = parsed.Message + } + + return apiErr +} + +// listPages reads every page of a v1 listing, up to portalsMaxPages pages of portalsPageSize items. +func listPages[T any]( + ctx context.Context, + fetch func(opts ...management.RequestOption) (items []T, hasNext bool, err error), + extra ...management.RequestOption, +) ([]T, error) { + var all []T + + for page := range portalsMaxPages { + if err := ctx.Err(); err != nil { + return nil, err + } + + opts := append([]management.RequestOption{management.Page(page), management.PerPage(portalsPageSize)}, extra...) + + items, hasNext, err := fetch(opts...) + if err != nil { + return nil, asPortalsError(err) + } + + all = append(all, items...) + if !hasNext { + return all, nil + } + } + + return nil, fmt.Errorf("the listing returned more than %d items and did not stop paging", portalsMaxPages*portalsPageSize) +} + +// pageAll reads every page of a v3 listing, up to portalsMaxPages pages. +func pageAll[C comparable, T any, R any]( + ctx context.Context, + first func(ctx context.Context) (*core.Page[C, T, R], error), +) ([]T, error) { + var all []T + + page, err := first(ctx) + if err != nil { + return nil, asPortalsError(err) + } + + for number := 0; page != nil && number < portalsMaxPages; number++ { + if err := ctx.Err(); err != nil { + return nil, err + } + + all = append(all, page.Results...) + + next, err := page.GetNextPage(ctx) + if errors.Is(err, core.ErrNoPages) { + return all, nil + } + + if err != nil { + return nil, asPortalsError(err) + } + + page = next + } + + if page != nil { + return nil, fmt.Errorf("the listing returned more than %d pages and did not stop paging", portalsMaxPages) + } + + return all, nil +} + +// The pages the generic helpers read, under the names the auth0 package gives them. +type ( + auth0ClientGrantPage = auth0.ClientGrantPage + auth0ConnectionPage = auth0.ConnectionForListPage + auth0ClientConnectionPage = auth0.ClientConnectionPage +) diff --git a/internal/cli/universal_portals_tenant_apps.go b/internal/cli/universal_portals_tenant_apps.go new file mode 100644 index 000000000..74636d0e4 --- /dev/null +++ b/internal/cli/universal_portals_tenant_apps.go @@ -0,0 +1,436 @@ +package cli + +import ( + "context" + "fmt" + + goauth0 "github.com/auth0/go-auth0" + "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + + "github.com/auth0/auth0-cli/internal/portals" +) + +// ListResourceServers implements portals.ResourceServerStore. +func (t *portalsTenant) ListResourceServers(ctx context.Context) ([]portals.ResourceServer, error) { + servers, err := listPages(ctx, func(opts ...management.RequestOption) ([]*management.ResourceServer, bool, error) { + list, err := t.api.ResourceServer.List(ctx, opts...) + if err != nil { + return nil, false, err + } + + return list.ResourceServers, list.HasNext(), nil + }) + if err != nil { + return nil, err + } + + listed := make([]portals.ResourceServer, 0, len(servers)) + for _, server := range servers { + listed = append(listed, portals.ResourceServer{ + ID: server.GetID(), + Identifier: server.GetIdentifier(), + Name: server.GetName(), + }) + } + + return listed, nil +} + +// CreateResourceServer implements portals.ResourceServerStore. +func (t *portalsTenant) CreateResourceServer(ctx context.Context, spec portals.ResourceServerSpec) (portals.ResourceServer, error) { + server := &management.ResourceServer{ + Name: goauth0.String(spec.Name), + Identifier: goauth0.String(spec.Identifier), + SkipConsentForVerifiableFirstPartyClients: goauth0.Bool(spec.SkipConsentForVerifiableFirstPartyClients), + } + if spec.TokenDialect != "" { + server.TokenDialect = goauth0.String(spec.TokenDialect) + } + + if err := t.written(t.api.ResourceServer.Create(ctx, server)); err != nil { + return portals.ResourceServer{}, asPortalsError(err) + } + + return portals.ResourceServer{ID: server.GetID(), Identifier: server.GetIdentifier(), Name: server.GetName()}, nil +} + +// ListClients implements portals.ClientStore. +func (t *portalsTenant) ListClients(ctx context.Context) ([]portals.Client, error) { + clients, err := listPages( + ctx, + func(opts ...management.RequestOption) ([]*management.Client, bool, error) { + list, err := t.api.Client.List(ctx, opts...) + if err != nil { + return nil, false, err + } + + return list.Clients, list.HasNext(), nil + }, + management.IncludeFields("client_id", "name", "client_metadata", "session_transfer"), + ) + if err != nil { + return nil, err + } + + listed := make([]portals.Client, 0, len(clients)) + for _, client := range clients { + listed = append(listed, clientFromSDK(client)) + } + + return listed, nil +} + +// ReadClient implements portals.ClientStore. +func (t *portalsTenant) ReadClient(ctx context.Context, id string) (portals.Client, error) { + client, err := t.api.Client.Read(ctx, id) + if err != nil { + return portals.Client{}, asPortalsError(err) + } + + read := clientFromSDK(client) + read.Secret = "" + + return read, nil +} + +// ReadClientSecret implements portals.ClientStore. The secret is empty when the login cannot read it. +func (t *portalsTenant) ReadClientSecret(ctx context.Context, id string) (string, error) { + client, err := t.api.Client.Read(ctx, id, management.IncludeFields("client_id", "client_secret")) + if err != nil { + return "", asPortalsError(err) + } + + return client.GetClientSecret(), nil +} + +// CreateClient implements portals.ClientStore. +func (t *portalsTenant) CreateClient(ctx context.Context, settings portals.ClientSettings) (portals.Client, error) { + client := clientToSDK(settings) + if err := t.written(t.api.Client.Create(ctx, client)); err != nil { + return portals.Client{}, asPortalsError(err) + } + + return clientFromSDK(client), nil +} + +// UpdateClient implements portals.ClientStore. +func (t *portalsTenant) UpdateClient(ctx context.Context, id string, settings portals.ClientSettings) error { + return asPortalsError(t.written(t.api.Client.Update(ctx, id, clientToSDK(settings)))) +} + +// clientToSDK maps the settings that are set. A setting left empty or nil is not sent. +func clientToSDK(settings portals.ClientSettings) *management.Client { + client := &management.Client{ + IsFirstParty: settings.IsFirstParty, + OIDCConformant: settings.OIDCConformant, + IsTokenEndpointIPHeaderTrusted: settings.IPHeaderTrusted, + RequireProofOfPossession: settings.RequireProofOfPossession, + } + + setString(&client.Name, settings.Name) + setString(&client.AppType, settings.AppType) + setString(&client.TokenEndpointAuthMethod, settings.TokenEndpointAuthMethod) + setString(&client.OrganizationUsage, settings.OrganizationUsage) + setString(&client.OrganizationRequireBehavior, settings.OrganizationRequireBehavior) + setStrings(&client.Callbacks, settings.Callbacks) + setStrings(&client.AllowedLogoutURLs, settings.AllowedLogoutURLs) + setStrings(&client.GrantTypes, settings.GrantTypes) + + if settings.Metadata != nil { + metadata := make(map[string]interface{}, len(settings.Metadata)) + for key, value := range settings.Metadata { + metadata[key] = value + } + client.ClientMetadata = &metadata + } + + if token := settings.RefreshToken; token != nil { + client.RefreshToken = refreshTokenToSDK(token) + } + + if transfer := settings.SessionTransfer; transfer != nil { + client.SessionTransfer = &management.SessionTransfer{} + setStrings(&client.SessionTransfer.AllowedAuthenticationMethods, transfer.AllowedAuthenticationMethods) + setString(&client.SessionTransfer.EnforceDeviceBinding, transfer.EnforceDeviceBinding) + } + + if logout := settings.OIDCLogout; logout != nil { + client.OIDCLogout = &management.OIDCLogout{ + BackChannelLogoutInitiators: &management.BackChannelLogoutInitiators{}, + } + setStrings(&client.OIDCLogout.BackChannelLogoutURLs, logout.BackchannelLogoutURLs) + setString(&client.OIDCLogout.BackChannelLogoutInitiators.Mode, logout.Mode) + setStrings(&client.OIDCLogout.BackChannelLogoutInitiators.SelectedInitiators, logout.SelectedInitiators) + } + + return client +} + +func refreshTokenToSDK(token *portals.RefreshToken) *management.ClientRefreshToken { + refresh := &management.ClientRefreshToken{ + TokenLifetime: token.TokenLifetime, + IdleTokenLifetime: token.IdleTokenLifetime, + InfiniteIdleTokenLifetime: token.InfiniteIdleTokenLifetime, + } + setString(&refresh.RotationType, token.RotationType) + setString(&refresh.ExpirationType, token.ExpirationType) + + if len(token.Policies) > 0 { + policies := make([]management.ClientRefreshTokenPolicy, 0, len(token.Policies)) + for _, policy := range token.Policies { + entry := management.ClientRefreshTokenPolicy{Audience: goauth0.String(policy.Audience)} + setStrings(&entry.Scope, policy.Scope) + policies = append(policies, entry) + } + refresh.Policies = &policies + } + + return refresh +} + +func setString(target **string, value string) { + if value != "" { + *target = goauth0.String(value) + } +} + +func setStrings(target **[]string, values []string) { + if len(values) > 0 { + copied := append([]string(nil), values...) + *target = &copied + } +} + +// clientFromSDK maps the parts of an application the portals package reads. +func clientFromSDK(client *management.Client) portals.Client { + settings := portals.ClientSettings{ + Name: client.GetName(), + AppType: client.GetAppType(), + IsFirstParty: client.IsFirstParty, + OIDCConformant: client.OIDCConformant, + TokenEndpointAuthMethod: client.GetTokenEndpointAuthMethod(), + IPHeaderTrusted: client.IsTokenEndpointIPHeaderTrusted, + RequireProofOfPossession: client.RequireProofOfPossession, + Callbacks: client.GetCallbacks(), + AllowedLogoutURLs: client.GetAllowedLogoutURLs(), + GrantTypes: client.GetGrantTypes(), + OrganizationUsage: client.GetOrganizationUsage(), + OrganizationRequireBehavior: client.GetOrganizationRequireBehavior(), + Metadata: metadataFromSDK(client.ClientMetadata), + } + + if token := client.RefreshToken; token != nil { + settings.RefreshToken = &portals.RefreshToken{ + RotationType: token.GetRotationType(), + ExpirationType: token.GetExpirationType(), + TokenLifetime: token.TokenLifetime, + IdleTokenLifetime: token.IdleTokenLifetime, + InfiniteIdleTokenLifetime: token.InfiniteIdleTokenLifetime, + } + + if token.Policies != nil { + for _, policy := range *token.Policies { + settings.RefreshToken.Policies = append(settings.RefreshToken.Policies, portals.RefreshTokenPolicy{ + Audience: policy.GetAudience(), + Scope: policy.GetScope(), + }) + } + } + } + + if transfer := client.SessionTransfer; transfer != nil { + settings.SessionTransfer = &portals.SessionTransfer{ + AllowedAuthenticationMethods: transfer.GetAllowedAuthenticationMethods(), + EnforceDeviceBinding: transfer.GetEnforceDeviceBinding(), + } + } + + if logout := client.OIDCLogout; logout != nil { + settings.OIDCLogout = &portals.OIDCLogout{BackchannelLogoutURLs: logout.GetBackChannelLogoutURLs()} + + if initiators := logout.BackChannelLogoutInitiators; initiators != nil { + settings.OIDCLogout.Mode = initiators.GetMode() + settings.OIDCLogout.SelectedInitiators = initiators.GetSelectedInitiators() + } + } + + return portals.Client{ + ID: client.GetClientID(), + Secret: client.GetClientSecret(), + ClientSettings: settings, + } +} + +// metadataFromSDK keeps the text values of the metadata, which is all the portals package sets. +func metadataFromSDK(metadata *map[string]interface{}) map[string]string { + if metadata == nil { + return nil + } + + text := make(map[string]string, len(*metadata)) + for key, value := range *metadata { + if asString, ok := value.(string); ok { + text[key] = asString + } + } + + return text +} + +// ListClientGrants implements portals.ClientGrantStore. +func (t *portalsTenant) ListClientGrants(ctx context.Context, clientID string) ([]portals.ClientGrant, error) { + grants, err := pageAll(ctx, func(ctx context.Context) (*auth0ClientGrantPage, error) { + return t.apiv3.ClientGrant.List(ctx, &managementv3.ListClientGrantsRequestParameters{ + ClientID: goauth0.String(clientID), + Take: goauth0.Int(portalsPageSize), + }) + }) + if err != nil { + return nil, err + } + + listed := make([]portals.ClientGrant, 0, len(grants)) + for _, grant := range grants { + listed = append(listed, portals.ClientGrant{ + ID: grant.GetID(), + ClientID: grant.GetClientID(), + Audience: grant.GetAudience(), + Scope: grant.GetScope(), + }) + } + + return listed, nil +} + +// CreateClientGrant implements portals.ClientGrantStore. +func (t *portalsTenant) CreateClientGrant(ctx context.Context, spec portals.ClientGrantSpec) (portals.ClientGrant, error) { + request := &managementv3.CreateClientGrantRequestContent{ + ClientID: goauth0.String(spec.ClientID), + Audience: spec.Audience, + Scope: spec.Scope, + } + + switch spec.SubjectType { + case "": + case "user": + subject := managementv3.ClientGrantSubjectTypeEnumUser + request.SubjectType = &subject + case "client": + subject := managementv3.ClientGrantSubjectTypeEnumClient + request.SubjectType = &subject + default: + return portals.ClientGrant{}, fmt.Errorf("unknown client grant subject type %q", spec.SubjectType) + } + + created, err := t.apiv3.ClientGrant.Create(ctx, request) + if err := t.written(err); err != nil { + return portals.ClientGrant{}, asPortalsError(err) + } + + return portals.ClientGrant{ + ID: created.GetID(), + ClientID: created.GetClientID(), + Audience: created.GetAudience(), + Scope: created.GetScope(), + }, nil +} + +// UpdateClientGrantScope implements portals.ClientGrantStore. +func (t *portalsTenant) UpdateClientGrantScope(ctx context.Context, id string, scope []string) error { + _, err := t.apiv3.ClientGrant.Update(ctx, id, &managementv3.UpdateClientGrantRequestContent{Scope: scope}) + + return asPortalsError(t.written(err)) +} + +// DeleteClientGrant implements portals.ClientGrantStore. +func (t *portalsTenant) DeleteClientGrant(ctx context.Context, id string) error { + return asPortalsError(t.written(t.apiv3.ClientGrant.Delete(ctx, id))) +} + +// ListVaultConnections implements portals.VaultStore. +func (t *portalsTenant) ListVaultConnections(ctx context.Context) ([]portals.VaultConnection, error) { + list, err := t.api.FlowVaultConnection.GetConnectionList(ctx, management.Page(0), management.PerPage(portalsPageSize)) + if err != nil { + return nil, asPortalsError(err) + } + + listed := make([]portals.VaultConnection, 0, len(list.Connections)) + for _, connection := range list.Connections { + listed = append(listed, portals.VaultConnection{ID: connection.GetID(), Name: connection.GetName()}) + } + + return listed, nil +} + +// CreateVaultConnection implements portals.VaultStore. +func (t *portalsTenant) CreateVaultConnection(ctx context.Context, name string, setup portals.VaultSetup) (portals.VaultConnection, error) { + connection := &management.FlowVaultConnection{ + AppID: goauth0.String("AUTH0"), + Name: goauth0.String(name), + Setup: vaultSetupToSDK(setup), + } + + if err := t.written(t.api.FlowVaultConnection.CreateConnection(ctx, connection)); err != nil { + return portals.VaultConnection{}, asPortalsError(err) + } + + return portals.VaultConnection{ID: connection.GetID(), Name: connection.GetName()}, nil +} + +// UpdateVaultConnection implements portals.VaultStore. +func (t *portalsTenant) UpdateVaultConnection(ctx context.Context, id string, setup portals.VaultSetup) error { + return asPortalsError(t.written(t.api.FlowVaultConnection.UpdateConnection(ctx, id, &management.FlowVaultConnection{ + Setup: vaultSetupToSDK(setup), + }))) +} + +func vaultSetupToSDK(setup portals.VaultSetup) *map[string]interface{} { + return &map[string]interface{}{ + "domain": setup.Domain, + "client_id": setup.ClientID, + "client_secret": setup.ClientSecret, + "type": "OAUTH_APP", + } +} + +// ListDatabaseConnections implements portals.ConnectionStore. +func (t *portalsTenant) ListDatabaseConnections(ctx context.Context) ([]portals.Connection, error) { + strategy := managementv3.ConnectionStrategyEnumAuth0 + + connections, err := pageAll(ctx, func(ctx context.Context) (*auth0ConnectionPage, error) { + return t.apiv3.Connection.List(ctx, &managementv3.ListConnectionsQueryParameters{ + Strategy: []*managementv3.ConnectionStrategyEnum{&strategy}, + Take: goauth0.Int(portalsPageSize), + }) + }) + if err != nil { + return nil, err + } + + return connectionsFromSDK(connections), nil +} + +// ListClientConnections implements portals.ConnectionStore. +func (t *portalsTenant) ListClientConnections(ctx context.Context, clientID string) ([]portals.Connection, error) { + connections, err := pageAll(ctx, func(ctx context.Context) (*auth0ClientConnectionPage, error) { + // The endpoint pages by checkpoint and rejects page and per_page. The request must not be nil. + return t.apiv3.ClientConnection.Get(ctx, clientID, &managementv3.ConnectionsGetRequest{ + Take: goauth0.Int(portalsPageSize), + }) + }) + if err != nil { + return nil, err + } + + return connectionsFromSDK(connections), nil +} + +func connectionsFromSDK(connections []*managementv3.ConnectionForList) []portals.Connection { + listed := make([]portals.Connection, 0, len(connections)) + for _, connection := range connections { + listed = append(listed, portals.Connection{ID: connection.GetID(), Name: connection.GetName()}) + } + + return listed +} diff --git a/internal/cli/universal_portals_tenant_apps_test.go b/internal/cli/universal_portals_tenant_apps_test.go new file mode 100644 index 000000000..0c31e7116 --- /dev/null +++ b/internal/cli/universal_portals_tenant_apps_test.go @@ -0,0 +1,459 @@ +package cli + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" +) + +func intPointer(value int) *int { return &value } + +func boolPointer(value bool) *bool { return &value } + +func TestPortalsTenant_Clients(t *testing.T) { + t.Parallel() + + t.Run("a list asks only for the fields it reads", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "clients", 200, `{"start":0,"limit":100,"total":1,"clients":[ + {"client_id":"cli_1","name":"Acme (Universal Portals)","client_metadata":{"portals_setup_slug":"acme","count":3}, + "session_transfer":{"allowed_authentication_methods":["query"]}}]}`) + + clients, err := tenant.ListClients(context.Background()) + + require.NoError(t, err) + require.Len(t, clients, 1) + assert.Equal(t, "cli_1", clients[0].ID) + assert.Equal(t, "Acme (Universal Portals)", clients[0].Name) + assert.Equal(t, map[string]string{"portals_setup_slug": "acme"}, clients[0].Metadata, "metadata that is not text is left out") + assert.Equal(t, []string{"query"}, clients[0].SessionTransfer.AllowedAuthenticationMethods) + assert.Equal(t, []string{"client_id,name,client_metadata,session_transfer"}, server.received(http.MethodGet, "clients")[0].Query["fields"]) + }) + + t.Run("a read returns the whole configuration and never the secret", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "clients/cli_1", 200, `{ + "client_id":"cli_1","client_secret":"must-not-be-returned","name":"Acme","app_type":"regular_web", + "is_first_party":true,"oidc_conformant":true,"token_endpoint_auth_method":"client_secret_post", + "is_token_endpoint_ip_header_trusted":true,"require_proof_of_possession":false, + "callbacks":["https://acme/cb"],"allowed_logout_urls":["https://acme"], + "grant_types":["authorization_code","refresh_token"], + "organization_usage":"allow","organization_require_behavior":"no_prompt", + "refresh_token":{"rotation_type":"non-rotating","expiration_type":"expiring","token_lifetime":86400, + "idle_token_lifetime":3600,"infinite_idle_token_lifetime":false, + "policies":[{"audience":"https://acme/me/","scope":["read:me:factors"]}]}, + "session_transfer":{"allowed_authentication_methods":["query"],"enforce_device_binding":"ip"}, + "oidc_logout":{"backchannel_logout_urls":["https://acme/logout"], + "backchannel_logout_initiators":{"mode":"custom","selected_initiators":["rp-logout"]}}}`) + + client, err := tenant.ReadClient(context.Background(), "cli_1") + + require.NoError(t, err) + assert.Empty(t, client.Secret) + assert.Equal(t, portals.Client{ + ID: "cli_1", + ClientSettings: portals.ClientSettings{ + Name: "Acme", + AppType: "regular_web", + IsFirstParty: boolPointer(true), + OIDCConformant: boolPointer(true), + TokenEndpointAuthMethod: "client_secret_post", + IPHeaderTrusted: boolPointer(true), + RequireProofOfPossession: boolPointer(false), + Callbacks: []string{"https://acme/cb"}, + AllowedLogoutURLs: []string{"https://acme"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + OrganizationUsage: "allow", + OrganizationRequireBehavior: "no_prompt", + RefreshToken: &portals.RefreshToken{ + RotationType: "non-rotating", + ExpirationType: "expiring", + TokenLifetime: intPointer(86400), + IdleTokenLifetime: intPointer(3600), + InfiniteIdleTokenLifetime: boolPointer(false), + Policies: []portals.RefreshTokenPolicy{ + {Audience: "https://acme/me/", Scope: []string{"read:me:factors"}}, + }, + }, + SessionTransfer: &portals.SessionTransfer{ + AllowedAuthenticationMethods: []string{"query"}, + EnforceDeviceBinding: "ip", + }, + OIDCLogout: &portals.OIDCLogout{ + BackchannelLogoutURLs: []string{"https://acme/logout"}, + Mode: "custom", + SelectedInitiators: []string{"rp-logout"}, + }, + }, + }, client) + }) + + t.Run("a secret is read on its own, and is empty when the login cannot read it", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "clients/cli_1", 200, `{"client_id":"cli_1","client_secret":"s3cr3t"}`) + server.reply(http.MethodGet, "clients/cli_2", 200, `{"client_id":"cli_2"}`) + + secret, err := tenant.ReadClientSecret(context.Background(), "cli_1") + require.NoError(t, err) + assert.Equal(t, "s3cr3t", secret) + + secret, err = tenant.ReadClientSecret(context.Background(), "cli_2") + require.NoError(t, err) + assert.Empty(t, secret) + assert.Equal(t, []string{"client_id,client_secret"}, server.received(http.MethodGet, "clients/cli_1")[0].Query["fields"]) + }) + + t.Run("a create sends every setting and returns the ID and the secret", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "clients", 201, `{"client_id":"cli_1","client_secret":"s3cr3t","name":"Acme"}`) + + created, err := tenant.CreateClient(context.Background(), portals.ClientSettings{ + Name: "Acme", + AppType: "regular_web", + IsFirstParty: boolPointer(true), + OIDCConformant: boolPointer(true), + TokenEndpointAuthMethod: "client_secret_post", + IPHeaderTrusted: boolPointer(true), + Callbacks: []string{"https://acme/cb"}, + AllowedLogoutURLs: []string{"https://acme"}, + GrantTypes: []string{"authorization_code"}, + OrganizationUsage: "allow", + OrganizationRequireBehavior: "no_prompt", + Metadata: map[string]string{"portals_setup_slug": "acme"}, + RefreshToken: &portals.RefreshToken{ + RotationType: "non-rotating", + ExpirationType: "expiring", + TokenLifetime: intPointer(86400), + IdleTokenLifetime: intPointer(3600), + Policies: []portals.RefreshTokenPolicy{{Audience: "https://acme/me/", Scope: []string{"read:me:factors"}}}, + }, + SessionTransfer: &portals.SessionTransfer{AllowedAuthenticationMethods: []string{"query"}, EnforceDeviceBinding: "ip"}, + OIDCLogout: &portals.OIDCLogout{ + BackchannelLogoutURLs: []string{"https://acme/logout"}, + Mode: "custom", + SelectedInitiators: []string{"rp-logout", "session-expired"}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, "cli_1", created.ID) + assert.Equal(t, "s3cr3t", created.Secret) + assert.JSONEq(t, `{ + "name":"Acme","app_type":"regular_web","is_first_party":true,"oidc_conformant":true, + "token_endpoint_auth_method":"client_secret_post","is_token_endpoint_ip_header_trusted":true, + "callbacks":["https://acme/cb"],"allowed_logout_urls":["https://acme"],"grant_types":["authorization_code"], + "organization_usage":"allow","organization_require_behavior":"no_prompt", + "client_metadata":{"portals_setup_slug":"acme"}, + "refresh_token":{"rotation_type":"non-rotating","expiration_type":"expiring","token_lifetime":86400, + "idle_token_lifetime":3600,"policies":[{"audience":"https://acme/me/","scope":["read:me:factors"]}]}, + "session_transfer":{"allowed_authentication_methods":["query"],"enforce_device_binding":"ip"}, + "oidc_logout":{"backchannel_logout_urls":["https://acme/logout"], + "backchannel_logout_initiators":{"mode":"custom","selected_initiators":["rp-logout","session-expired"]}}}`, + server.received(http.MethodPost, "clients")[0].Body) + }) + + t.Run("an update sends only the settings that are set", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPatch, "clients/cli_1", 200, `{"client_id":"cli_1"}`) + + err := tenant.UpdateClient(context.Background(), "cli_1", portals.ClientSettings{ + Callbacks: []string{"https://acme/cb"}, + IPHeaderTrusted: boolPointer(false), + }) + + require.NoError(t, err) + assert.JSONEq(t, + `{"callbacks":["https://acme/cb"],"is_token_endpoint_ip_header_trusted":false}`, + server.received(http.MethodPatch, "clients/cli_1")[0].Body, + ) + }) +} + +func TestPortalsTenant_ClientGrants(t *testing.T) { + t.Parallel() + + t.Run("a list follows the checkpoint and filters by application", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.handle(http.MethodGet, "client-grants", func(r receivedRequest) (int, string) { + if r.Query["from"] == nil { + return 200, `{"client_grants":[{"id":"cgr_1","client_id":"cli_1","audience":"https://acme/me/","scope":["a"]}],"next":"cursor"}` + } + + return 200, `{"client_grants":[{"id":"cgr_2","client_id":"cli_1","audience":"https://acme/my-org/","scope":["b","c"]}]}` + }) + + grants, err := tenant.ListClientGrants(context.Background(), "cli_1") + + require.NoError(t, err) + assert.Equal(t, []portals.ClientGrant{ + {ID: "cgr_1", ClientID: "cli_1", Audience: "https://acme/me/", Scope: []string{"a"}}, + {ID: "cgr_2", ClientID: "cli_1", Audience: "https://acme/my-org/", Scope: []string{"b", "c"}}, + }, grants) + + requests := server.received(http.MethodGet, "client-grants") + require.Len(t, requests, 2) + assert.Equal(t, []string{"cli_1"}, requests[0].Query["client_id"]) + assert.Equal(t, []string{"cursor"}, requests[1].Query["from"]) + }) + + tests := []struct { + name string + subjectType string + wantSubject any + wantErr string + }{ + {name: "a grant for users", subjectType: "user", wantSubject: "user"}, + {name: "a grant for the application itself", subjectType: "client", wantSubject: "client"}, + {name: "a grant that leaves the subject to the API", subjectType: ""}, + {name: "a subject type nobody knows", subjectType: "robot", wantErr: `unknown client grant subject type "robot"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "client-grants", 201, `{"id":"cgr_1","client_id":"cli_1","audience":"https://acme/me/","scope":["a"]}`) + + grant, err := tenant.CreateClientGrant(context.Background(), portals.ClientGrantSpec{ + ClientID: "cli_1", + Audience: "https://acme/me/", + Scope: []string{"a"}, + SubjectType: tt.subjectType, + }) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + assert.Empty(t, server.received(http.MethodPost, "client-grants")) + return + } + + require.NoError(t, err) + assert.Equal(t, "cgr_1", grant.ID) + + body := server.received(http.MethodPost, "client-grants")[0].json(t) + assert.Equal(t, "cli_1", body["client_id"]) + assert.Equal(t, tt.wantSubject, body["subject_type"]) + }) + } + + t.Run("an update sends the scopes", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPatch, "client-grants/cgr_1", 200, `{"id":"cgr_1"}`) + + require.NoError(t, tenant.UpdateClientGrantScope(context.Background(), "cgr_1", []string{"a", "b"})) + + assert.JSONEq(t, `{"scope":["a","b"]}`, server.received(http.MethodPatch, "client-grants/cgr_1")[0].Body) + }) +} + +func TestPortalsTenant_Vault(t *testing.T) { + t.Parallel() + + setup := portals.VaultSetup{Domain: "acme.us.auth0.com", ClientID: "cli_2", ClientSecret: "s3cr3t"} + + t.Run("a create sends the setup of an Auth0 application", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "flows/vault/connections", 201, `{"id":"ac_1","name":"Universal Portals forms vault"}`) + + created, err := tenant.CreateVaultConnection(context.Background(), "Universal Portals forms vault", setup) + + require.NoError(t, err) + assert.Equal(t, portals.VaultConnection{ID: "ac_1", Name: "Universal Portals forms vault"}, created) + assert.JSONEq(t, `{ + "app_id":"AUTH0","name":"Universal Portals forms vault", + "setup":{"domain":"acme.us.auth0.com","client_id":"cli_2","client_secret":"s3cr3t","type":"OAUTH_APP"}}`, + server.received(http.MethodPost, "flows/vault/connections")[0].Body) + }) + + t.Run("an update sends only the setup", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPatch, "flows/vault/connections/ac_1", 200, `{"id":"ac_1"}`) + + require.NoError(t, tenant.UpdateVaultConnection(context.Background(), "ac_1", setup)) + + assert.JSONEq(t, + `{"setup":{"domain":"acme.us.auth0.com","client_id":"cli_2","client_secret":"s3cr3t","type":"OAUTH_APP"}}`, + server.received(http.MethodPatch, "flows/vault/connections/ac_1")[0].Body, + ) + }) + + t.Run("a list returns the IDs and the names", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "flows/vault/connections", 200, `{"connections":[{"id":"ac_1","name":"Vault"}]}`) + + listed, err := tenant.ListVaultConnections(context.Background()) + + require.NoError(t, err) + assert.Equal(t, []portals.VaultConnection{{ID: "ac_1", Name: "Vault"}}, listed) + }) +} + +func TestPortalsTenant_Connections(t *testing.T) { + t.Parallel() + + t.Run("the connections of an application page by checkpoint and never by page", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.handle(http.MethodGet, "clients/cli_1/connections", func(r receivedRequest) (int, string) { + if r.Query["from"] == nil { + return 200, `{"connections":[{"id":"con_1","name":"db"}],"next":"cursor"}` + } + + return 200, `{"connections":[{"id":"con_2","name":"google"}]}` + }) + + connections, err := tenant.ListClientConnections(context.Background(), "cli_1") + + require.NoError(t, err) + assert.Equal(t, []portals.Connection{{ID: "con_1", Name: "db"}, {ID: "con_2", Name: "google"}}, connections) + + for _, request := range server.received(http.MethodGet, "clients/cli_1/connections") { + assert.Equal(t, []string{"100"}, request.Query["take"]) + assert.NotContains(t, request.Query, "page") + assert.NotContains(t, request.Query, "per_page") + } + }) + + t.Run("the database connections are asked for by strategy", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "connections", 200, `{"connections":[{"id":"con_1","name":"db","strategy":"auth0"}]}`) + + connections, err := tenant.ListDatabaseConnections(context.Background()) + + require.NoError(t, err) + assert.Equal(t, []portals.Connection{{ID: "con_1", Name: "db"}}, connections) + assert.Equal(t, []string{"auth0"}, server.received(http.MethodGet, "connections")[0].Query["strategy"]) + }) +} + +func TestPortalsTenant_TracksWhetherItChangedTheTenant(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("reads and checks do not change it", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "resource-servers", 200, `{"resource_servers":[],"total":0}`) + server.reply(http.MethodPost, "clients", 400, `{"statusCode":400,"message":"Payload validation error"}`) + + _, err := tenant.ListResourceServers(ctx) + require.NoError(t, err) + assert.Error(t, tenant.Probe(ctx, http.MethodPost, "clients", map[string]any{})) + + assert.False(t, tenant.hasWritten()) + }) + + t.Run("a request that failed did not change it", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "resource-servers", 409, `{"statusCode":409,"message":"already exists"}`) + + _, err := tenant.CreateResourceServer(ctx, portals.ResourceServerSpec{Identifier: "https://acme/me/"}) + require.Error(t, err) + + assert.False(t, tenant.hasWritten()) + }) + + writes := map[string]func(tenant *portalsTenant) error{ + "a created resource server": func(tenant *portalsTenant) error { + _, err := tenant.CreateResourceServer(ctx, portals.ResourceServerSpec{Identifier: "https://acme/me/"}) + return err + }, + "a created application": func(tenant *portalsTenant) error { + _, err := tenant.CreateClient(ctx, portals.ClientSettings{Name: "Acme"}) + return err + }, + "an updated application": func(tenant *portalsTenant) error { + return tenant.UpdateClient(ctx, "cli_1", portals.ClientSettings{Name: "Acme"}) + }, + "a created grant": func(tenant *portalsTenant) error { + _, err := tenant.CreateClientGrant(ctx, portals.ClientGrantSpec{ClientID: "cli_1", Audience: "https://acme/me/"}) + return err + }, + "an updated grant": func(tenant *portalsTenant) error { + return tenant.UpdateClientGrantScope(ctx, "cgr_1", []string{"a"}) + }, + "a created vault connection": func(tenant *portalsTenant) error { + _, err := tenant.CreateVaultConnection(ctx, "Vault", portals.VaultSetup{}) + return err + }, + "an imported form": func(tenant *portalsTenant) error { + _, err := tenant.ImportForm(ctx, map[string]any{"form": map[string]any{"name": "Profile"}}) + return err + }, + "a created portal": func(tenant *portalsTenant) error { + _, err := tenant.CreatePortal(ctx, portals.CreatePortalRequest{Slug: "acme", Client: &portals.PortalClient{ClientID: "cli_1"}}) + return err + }, + "a created organization": func(tenant *portalsTenant) error { + _, err := tenant.CreateOrganization(ctx, portals.OrganizationSpec{Name: "acme"}) + return err + }, + "a member added to an organization": func(tenant *portalsTenant) error { + return tenant.AddOrganizationMember(ctx, "org_1", "auth0|a") + }, + "a role assigned to a member": func(tenant *portalsTenant) error { + return tenant.AssignMemberRole(ctx, "org_1", "auth0|a", "rol_1") + }, + "a deleted grant": func(tenant *portalsTenant) error { + return tenant.DeleteClientGrant(ctx, "cgr_1") + }, + "a created role": func(tenant *portalsTenant) error { + _, err := tenant.CreateRole(ctx, portals.RoleSpec{Name: "Admin"}) + return err + }, + } + + for name, write := range writes { + t.Run(name+" changes it", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + for _, route := range []struct{ method, path string }{ + {http.MethodPost, "resource-servers"}, {http.MethodPost, "clients"}, {http.MethodPatch, "clients/cli_1"}, + {http.MethodPost, "client-grants"}, {http.MethodPatch, "client-grants/cgr_1"}, {http.MethodDelete, "client-grants/cgr_1"}, + {http.MethodPost, "flows/vault/connections"}, {http.MethodPost, "forms/import"}, {http.MethodPost, "portals"}, + {http.MethodPost, "organizations"}, {http.MethodPost, "organizations/org_1/members"}, + {http.MethodPost, "organizations/org_1/members/auth0%7Ca/roles"}, {http.MethodPost, "roles"}, + } { + server.reply(route.method, route.path, 200, `{"id":"x","client_id":"x","form":{"id":"ap_1"}}`) + } + + require.NoError(t, write(tenant)) + + assert.True(t, tenant.hasWritten()) + }) + } +} diff --git a/internal/cli/universal_portals_tenant_directory.go b/internal/cli/universal_portals_tenant_directory.go new file mode 100644 index 000000000..b71b58f82 --- /dev/null +++ b/internal/cli/universal_portals_tenant_directory.go @@ -0,0 +1,296 @@ +package cli + +import ( + "context" + "strings" + + goauth0 "github.com/auth0/go-auth0" + "github.com/auth0/go-auth0/management" + + "github.com/auth0/auth0-cli/internal/portals" +) + +// ReadDefaultCustomDomain implements portals.CustomDomainStore. +func (t *portalsTenant) ReadDefaultCustomDomain(ctx context.Context) (portals.CustomDomain, error) { + domain, err := t.api.CustomDomain.ReadDefault(ctx) + if err != nil { + return portals.CustomDomain{}, asPortalsError(err) + } + + return portals.CustomDomain{Domain: domain.GetDomain(), Status: domain.GetStatus()}, nil +} + +// SearchUsersByEmail implements portals.UserStore. +func (t *portalsTenant) SearchUsersByEmail(ctx context.Context, email string) ([]portals.User, error) { + users, err := listPages( + ctx, + func(opts ...management.RequestOption) ([]*management.User, bool, error) { + list, err := t.api.User.Search(ctx, opts...) + if err != nil { + return nil, false, err + } + + return list.Users, list.HasNext(), nil + }, + management.Query(`email:"`+escapeSearchPhrase(email)+`"`), + ) + if err != nil { + return nil, err + } + + return usersFromSDK(users), nil +} + +// escapeSearchPhrase escapes the characters that end or change a quoted phrase of the user search. +func escapeSearchPhrase(phrase string) string { + return strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(phrase) +} + +// ReadUser implements portals.UserStore. +func (t *portalsTenant) ReadUser(ctx context.Context, id string) (portals.User, error) { + user, err := t.api.User.Read(ctx, id) + if err != nil { + return portals.User{}, asPortalsError(err) + } + + return userFromSDK(user), nil +} + +func usersFromSDK(users []*management.User) []portals.User { + listed := make([]portals.User, 0, len(users)) + for _, user := range users { + listed = append(listed, userFromSDK(user)) + } + + return listed +} + +func userFromSDK(user *management.User) portals.User { + return portals.User{ID: user.GetID(), Email: user.GetEmail(), Name: user.GetName()} +} + +// ListUserOrganizations implements portals.UserStore. +func (t *portalsTenant) ListUserOrganizations(ctx context.Context, userID string) ([]portals.Organization, error) { + organizations, err := listPages(ctx, func(opts ...management.RequestOption) ([]*management.Organization, bool, error) { + list, err := t.api.User.Organizations(ctx, userID, opts...) + if err != nil { + return nil, false, err + } + + return list.Organizations, list.HasNext(), nil + }) + if err != nil { + return nil, err + } + + return organizationsFromSDK(organizations), nil +} + +// ListOrganizations implements portals.OrganizationStore. +func (t *portalsTenant) ListOrganizations(ctx context.Context) ([]portals.Organization, error) { + organizations, err := listPages(ctx, func(opts ...management.RequestOption) ([]*management.Organization, bool, error) { + list, err := t.api.Organization.List(ctx, opts...) + if err != nil { + return nil, false, err + } + + return list.Organizations, list.HasNext(), nil + }) + if err != nil { + return nil, err + } + + return organizationsFromSDK(organizations), nil +} + +// ReadOrganization implements portals.OrganizationStore. +func (t *portalsTenant) ReadOrganization(ctx context.Context, id string) (portals.Organization, error) { + organization, err := t.api.Organization.Read(ctx, id) + if err != nil { + return portals.Organization{}, asPortalsError(err) + } + + return organizationFromSDK(organization), nil +} + +// CreateOrganization implements portals.OrganizationStore. +func (t *portalsTenant) CreateOrganization(ctx context.Context, spec portals.OrganizationSpec) (portals.Organization, error) { + organization := &management.Organization{ + Name: goauth0.String(spec.Name), + DisplayName: goauth0.String(spec.DisplayName), + } + + if err := t.written(t.api.Organization.Create(ctx, organization)); err != nil { + return portals.Organization{}, asPortalsError(err) + } + + return organizationFromSDK(organization), nil +} + +func organizationsFromSDK(organizations []*management.Organization) []portals.Organization { + listed := make([]portals.Organization, 0, len(organizations)) + for _, organization := range organizations { + listed = append(listed, organizationFromSDK(organization)) + } + + return listed +} + +func organizationFromSDK(organization *management.Organization) portals.Organization { + return portals.Organization{ + ID: organization.GetID(), + Name: organization.GetName(), + DisplayName: organization.GetDisplayName(), + } +} + +// ListOrganizationConnections implements portals.OrganizationStore. +func (t *portalsTenant) ListOrganizationConnections(ctx context.Context, id string) ([]string, error) { + connections, err := listPages(ctx, func(opts ...management.RequestOption) ([]*management.OrganizationConnection, bool, error) { + list, err := t.api.Organization.Connections(ctx, id, opts...) + if err != nil { + return nil, false, err + } + + // A dry run answers a read about an organization it would create with an empty object. + if list == nil { + return nil, false, nil + } + + return list.OrganizationConnections, list.HasNext(), nil + }) + if err != nil { + return nil, err + } + + ids := make([]string, 0, len(connections)) + for _, connection := range connections { + ids = append(ids, connection.GetConnectionID()) + } + + return ids, nil +} + +// EnableOrganizationConnection implements portals.OrganizationStore. +func (t *portalsTenant) EnableOrganizationConnection(ctx context.Context, id, connectionID string) error { + return asPortalsError(t.written(t.api.Organization.AddConnection(ctx, id, &management.OrganizationConnection{ + ConnectionID: goauth0.String(connectionID), + AssignMembershipOnLogin: goauth0.Bool(false), + }))) +} + +// AddOrganizationMember implements portals.OrganizationStore. +func (t *portalsTenant) AddOrganizationMember(ctx context.Context, id, userID string) error { + return asPortalsError(t.written(t.api.Organization.AddMembers(ctx, id, []string{userID}))) +} + +// ListMemberRoles implements portals.OrganizationStore. +func (t *portalsTenant) ListMemberRoles(ctx context.Context, id, userID string) ([]portals.Role, error) { + roles, err := listPages(ctx, func(opts ...management.RequestOption) ([]management.OrganizationMemberRole, bool, error) { + list, err := t.api.Organization.MemberRoles(ctx, id, userID, opts...) + if err != nil { + return nil, false, err + } + + // A dry run answers a read about an organization it would create with an empty object. + if list == nil { + return nil, false, nil + } + + return list.Roles, list.HasNext(), nil + }) + if err != nil { + return nil, err + } + + listed := make([]portals.Role, 0, len(roles)) + for _, role := range roles { + listed = append(listed, portals.Role{ID: role.GetID(), Name: role.GetName()}) + } + + return listed, nil +} + +// AssignMemberRole implements portals.OrganizationStore. +func (t *portalsTenant) AssignMemberRole(ctx context.Context, id, userID, roleID string) error { + return asPortalsError(t.written(t.api.Organization.AssignMemberRoles(ctx, id, userID, []string{roleID}))) +} + +// ListRoles implements portals.RoleStore. +func (t *portalsTenant) ListRoles(ctx context.Context) ([]portals.Role, error) { + roles, err := listPages(ctx, func(opts ...management.RequestOption) ([]*management.Role, bool, error) { + list, err := t.api.Role.List(ctx, opts...) + if err != nil { + return nil, false, err + } + + return list.Roles, list.HasNext(), nil + }) + if err != nil { + return nil, err + } + + listed := make([]portals.Role, 0, len(roles)) + for _, role := range roles { + listed = append(listed, portals.Role{ID: role.GetID(), Name: role.GetName()}) + } + + return listed, nil +} + +// CreateRole implements portals.RoleStore. +func (t *portalsTenant) CreateRole(ctx context.Context, spec portals.RoleSpec) (portals.Role, error) { + role := &management.Role{ + Name: goauth0.String(spec.Name), + Description: goauth0.String(spec.Description), + } + + if err := t.written(t.api.Role.Create(ctx, role)); err != nil { + return portals.Role{}, asPortalsError(err) + } + + return portals.Role{ID: role.GetID(), Name: role.GetName()}, nil +} + +// ListRolePermissions implements portals.RoleStore. +func (t *portalsTenant) ListRolePermissions(ctx context.Context, roleID string) ([]portals.Permission, error) { + permissions, err := listPages(ctx, func(opts ...management.RequestOption) ([]*management.Permission, bool, error) { + list, err := t.api.Role.Permissions(ctx, roleID, opts...) + if err != nil { + return nil, false, err + } + + // A dry run answers a read about a role it would create with an empty object. + if list == nil { + return nil, false, nil + } + + return list.Permissions, list.HasNext(), nil + }) + if err != nil { + return nil, err + } + + listed := make([]portals.Permission, 0, len(permissions)) + for _, permission := range permissions { + listed = append(listed, portals.Permission{ + Name: permission.GetName(), + ResourceServerIdentifier: permission.GetResourceServerIdentifier(), + }) + } + + return listed, nil +} + +// AddRolePermissions implements portals.RoleStore. +func (t *portalsTenant) AddRolePermissions(ctx context.Context, roleID string, permissions []portals.Permission) error { + sdkPermissions := make([]*management.Permission, 0, len(permissions)) + for _, permission := range permissions { + sdkPermissions = append(sdkPermissions, &management.Permission{ + Name: goauth0.String(permission.Name), + ResourceServerIdentifier: goauth0.String(permission.ResourceServerIdentifier), + }) + } + + return asPortalsError(t.written(t.api.Role.AssociatePermissions(ctx, roleID, sdkPermissions))) +} diff --git a/internal/cli/universal_portals_tenant_directory_test.go b/internal/cli/universal_portals_tenant_directory_test.go new file mode 100644 index 000000000..9d64ff9d7 --- /dev/null +++ b/internal/cli/universal_portals_tenant_directory_test.go @@ -0,0 +1,270 @@ +package cli + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" +) + +func TestPortalsTenant_CustomDomain(t *testing.T) { + t.Parallel() + + t.Run("it reads the default domain", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "custom-domains/default", 200, `{"domain":"login.acme.com","status":"ready"}`) + + domain, err := tenant.ReadDefaultCustomDomain(context.Background()) + + require.NoError(t, err) + assert.Equal(t, portals.CustomDomain{Domain: "login.acme.com", Status: "ready"}, domain) + }) + + t.Run("a tenant without one is a 404", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "custom-domains/default", 404, `{"statusCode":404,"message":"Not Found"}`) + + _, err := tenant.ReadDefaultCustomDomain(context.Background()) + + assert.True(t, portals.IsStatus(err, http.StatusNotFound), "got %v", err) + }) +} + +func TestPortalsTenant_Users(t *testing.T) { + t.Parallel() + + t.Run("the email is searched as an exact phrase, with its quotes escaped", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "users", 200, `{"start":0,"limit":100,"total":1,"users":[ + {"user_id":"auth0|abc","email":"jane@acme.com","name":"Jane"}]}`) + + users, err := tenant.SearchUsersByEmail(context.Background(), `ja"ne@acme.com`) + + require.NoError(t, err) + assert.Equal(t, []portals.User{{ID: "auth0|abc", Email: "jane@acme.com", Name: "Jane"}}, users) + + query := server.received(http.MethodGet, "users")[0].Query + assert.Equal(t, []string{`email:"ja\"ne@acme.com"`}, query["q"]) + assert.Equal(t, []string{"v3"}, query["search_engine"]) + }) + + t.Run("an ID with a pipe is escaped once", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "users/auth0%7Cabc", 200, `{"user_id":"auth0|abc","email":"jane@acme.com"}`) + + user, err := tenant.ReadUser(context.Background(), "auth0|abc") + + require.NoError(t, err) + assert.Equal(t, "auth0|abc", user.ID) + }) + + t.Run("a user that does not exist is a 404", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "users/auth0%7Cnope", 404, `{"statusCode":404,"message":"The user does not exist."}`) + + _, err := tenant.ReadUser(context.Background(), "auth0|nope") + + assert.True(t, portals.IsStatus(err, http.StatusNotFound), "got %v", err) + }) + + t.Run("the organizations of a user", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "users/auth0%7Cabc/organizations", 200, `{"start":0,"limit":100,"total":1,"organizations":[ + {"id":"org_1","name":"acme","display_name":"Acme Corp"}]}`) + + organizations, err := tenant.ListUserOrganizations(context.Background(), "auth0|abc") + + require.NoError(t, err) + assert.Equal(t, []portals.Organization{{ID: "org_1", Name: "acme", DisplayName: "Acme Corp"}}, organizations) + }) +} + +func TestPortalsTenant_Organizations(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("it lists, reads and creates", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "organizations", 200, `{"start":0,"limit":100,"total":1,"organizations":[{"id":"org_1","name":"acme","display_name":"Acme"}]}`) + server.reply(http.MethodGet, "organizations/org_1", 200, `{"id":"org_1","name":"acme","display_name":"Acme"}`) + server.reply(http.MethodPost, "organizations", 201, `{"id":"org_2","name":"globex","display_name":"Globex"}`) + + listed, err := tenant.ListOrganizations(ctx) + require.NoError(t, err) + assert.Equal(t, []portals.Organization{{ID: "org_1", Name: "acme", DisplayName: "Acme"}}, listed) + + read, err := tenant.ReadOrganization(ctx, "org_1") + require.NoError(t, err) + assert.Equal(t, "acme", read.Name) + + created, err := tenant.CreateOrganization(ctx, portals.OrganizationSpec{Name: "globex", DisplayName: "Globex"}) + require.NoError(t, err) + assert.Equal(t, portals.Organization{ID: "org_2", Name: "globex", DisplayName: "Globex"}, created) + assert.JSONEq(t, `{"name":"globex","display_name":"Globex"}`, server.received(http.MethodPost, "organizations")[0].Body) + }) + + t.Run("a name that is taken is a 409", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "organizations", 409, `{"statusCode":409,"message":"The organization name is already in use."}`) + + _, err := tenant.CreateOrganization(ctx, portals.OrganizationSpec{Name: "acme"}) + + assert.True(t, portals.IsStatus(err, http.StatusConflict), "got %v", err) + }) + + t.Run("a connection is enabled without assigning membership on login", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "organizations/org_1/enabled_connections", 201, `{"connection_id":"con_1"}`) + server.reply(http.MethodGet, "organizations/org_1/enabled_connections", 200, `{"start":0,"limit":100,"total":1,"enabled_connections":[{"connection_id":"con_1"}]}`) + + require.NoError(t, tenant.EnableOrganizationConnection(ctx, "org_1", "con_1")) + ids, err := tenant.ListOrganizationConnections(ctx, "org_1") + + require.NoError(t, err) + assert.Equal(t, []string{"con_1"}, ids) + assert.JSONEq(t, + `{"connection_id":"con_1","assign_membership_on_login":false}`, + server.received(http.MethodPost, "organizations/org_1/enabled_connections")[0].Body, + ) + }) + + t.Run("members and their roles", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "organizations/org_1/members", 204, ``) + server.reply(http.MethodPost, "organizations/org_1/members/auth0%7Cabc/roles", 204, ``) + server.reply(http.MethodGet, "organizations/org_1/members/auth0%7Cabc/roles", 200, `{"start":0,"limit":100,"total":1,"roles":[{"id":"rol_1","name":"Admin"}]}`) + + require.NoError(t, tenant.AddOrganizationMember(ctx, "org_1", "auth0|abc")) + require.NoError(t, tenant.AssignMemberRole(ctx, "org_1", "auth0|abc", "rol_1")) + roles, err := tenant.ListMemberRoles(ctx, "org_1", "auth0|abc") + + require.NoError(t, err) + assert.Equal(t, []portals.Role{{ID: "rol_1", Name: "Admin"}}, roles) + assert.JSONEq(t, `{"members":["auth0|abc"]}`, server.received(http.MethodPost, "organizations/org_1/members")[0].Body) + assert.JSONEq(t, `{"roles":["rol_1"]}`, server.received(http.MethodPost, "organizations/org_1/members/auth0%7Cabc/roles")[0].Body) + }) +} + +func TestPortalsTenant_Roles(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + ctx := context.Background() + server.reply(http.MethodGet, "roles", 200, `{"start":0,"limit":100,"total":1,"roles":[{"id":"rol_1","name":"Admin"}]}`) + server.reply(http.MethodPost, "roles", 200, `{"id":"rol_2","name":"Universal Portals - Admin"}`) + server.reply(http.MethodGet, "roles/rol_1/permissions", 200, `{"start":0,"limit":100,"total":1,"permissions":[ + {"permission_name":"read:my_org:details","resource_server_identifier":"https://acme/my-org/"}]}`) + server.reply(http.MethodPost, "roles/rol_1/permissions", 201, ``) + + roles, err := tenant.ListRoles(ctx) + require.NoError(t, err) + assert.Equal(t, []portals.Role{{ID: "rol_1", Name: "Admin"}}, roles) + + created, err := tenant.CreateRole(ctx, portals.RoleSpec{Name: "Universal Portals - Admin", Description: "Organization permissions."}) + require.NoError(t, err) + assert.Equal(t, portals.Role{ID: "rol_2", Name: "Universal Portals - Admin"}, created) + assert.JSONEq(t, + `{"name":"Universal Portals - Admin","description":"Organization permissions."}`, + server.received(http.MethodPost, "roles")[0].Body, + ) + + permissions, err := tenant.ListRolePermissions(ctx, "rol_1") + require.NoError(t, err) + assert.Equal(t, []portals.Permission{{Name: "read:my_org:details", ResourceServerIdentifier: "https://acme/my-org/"}}, permissions) + + require.NoError(t, tenant.AddRolePermissions(ctx, "rol_1", []portals.Permission{ + {Name: "update:my_org:details", ResourceServerIdentifier: "https://acme/my-org/"}, + })) + assert.JSONEq(t, + `{"permissions":[{"permission_name":"update:my_org:details","resource_server_identifier":"https://acme/my-org/"}]}`, + server.received(http.MethodPost, "roles/rol_1/permissions")[0].Body, + ) +} + +// A dry run answers every read about something it would create, such as an organization, with an empty +// object, and counts on each listing reading that as an empty list. +func TestPortalsTenant_AnEmptyObjectIsAnEmptyList(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + list func(tenant *portalsTenant) (int, error) + }{ + { + name: "the roles of a member", + path: "organizations/org_new/members/auth0%7Cabc/roles", + list: func(tenant *portalsTenant) (int, error) { + roles, err := tenant.ListMemberRoles(context.Background(), "org_new", "auth0|abc") + + return len(roles), err + }, + }, + { + name: "the connections of an organization", + path: "organizations/org_new/enabled_connections", + list: func(tenant *portalsTenant) (int, error) { + ids, err := tenant.ListOrganizationConnections(context.Background(), "org_new") + + return len(ids), err + }, + }, + { + name: "the permissions of a role", + path: "roles/rol_new/permissions", + list: func(tenant *portalsTenant) (int, error) { + permissions, err := tenant.ListRolePermissions(context.Background(), "rol_new") + + return len(permissions), err + }, + }, + { + name: "the connections of an application", + path: "clients/cli_new/connections", + list: func(tenant *portalsTenant) (int, error) { + connections, err := tenant.ListClientConnections(context.Background(), "cli_new") + + return len(connections), err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, tt.path, 200, `{}`) + + count, err := tt.list(tenant) + + require.NoError(t, err) + assert.Zero(t, count) + }) + } +} diff --git a/internal/cli/universal_portals_tenant_raw.go b/internal/cli/universal_portals_tenant_raw.go new file mode 100644 index 000000000..a820cb580 --- /dev/null +++ b/internal/cli/universal_portals_tenant_raw.go @@ -0,0 +1,305 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + goauth0 "github.com/auth0/go-auth0" + managementv3 "github.com/auth0/go-auth0/v3/management" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/portals" +) + +// portalsRequestTimeout is how long a raw request may take. The command has no deadline of its own, +// so a slow tenant cannot cancel a setup that is half way. +const portalsRequestTimeout = 30 * time.Second + +// rawJSON sends a request that no typed client can send and returns the body of the response. The +// path is relative to the Management API, and may carry a query. It is for paths the CLI wrote +// itself: an id that came from the user goes through rawJSONAt. +func (t *portalsTenant) rawJSON(ctx context.Context, method, path string, body any) (json.RawMessage, error) { + route, query, hasQuery := strings.Cut(path, "?") + if hasQuery { + query = "?" + query + } + + return t.rawJSONAt(ctx, method, strings.Split(route, "/"), query, body) +} + +// rawJSONAt is rawJSON for a route given as its segments, each escaped, so an id with a "/" or a "?" +// stays one segment and cannot change the route or add a query. The query, if any, starts with "?". +func (t *portalsTenant) rawJSONAt(ctx context.Context, method string, segments []string, query string, body any) (json.RawMessage, error) { + ctx, cancel := context.WithTimeout(ctx, portalsRequestTimeout) + defer cancel() + + route := strings.Join(segments, "/") + uri := t.api.HTTPClient.URI(segments...) + query + + request, err := t.api.HTTPClient.NewRequest(ctx, method, uri, body) + if err != nil { + return nil, fmt.Errorf("build the %s %s request: %w", method, route, err) + } + + response, err := t.api.HTTPClient.Do(request) + if err != nil { + return nil, fmt.Errorf("send the %s %s request: %w", method, route, err) + } + + defer func() { + _ = response.Body.Close() + }() + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("read the response to %s %s: %w", method, route, err) + } + + if response.StatusCode >= http.StatusBadRequest { + return nil, apiErrorFromBody(response.StatusCode, data) + } + + return data, nil +} + +// rawList reads every page of a listing that pages with page and per_page. The listing is an array, +// or an object that holds the array under key. +func rawList[T any](ctx context.Context, t *portalsTenant, route, key string) ([]T, error) { + var all []T + + for page := range portalsMaxPages { + if err := ctx.Err(); err != nil { + return nil, err + } + + path := route + "?page=" + strconv.Itoa(page) + "&per_page=" + strconv.Itoa(portalsPageSize) + + data, err := t.rawJSON(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + items, err := listItems[T](data, key) + if err != nil { + return nil, fmt.Errorf("read the listing of %s: %w", route, err) + } + + all = append(all, items...) + if len(items) < portalsPageSize { + return all, nil + } + } + + return nil, fmt.Errorf("%s returned more than %d items and did not stop paging", route, portalsMaxPages*portalsPageSize) +} + +// listItems reads a listing that is an array, or an object that holds the array under key. +func listItems[T any](data []byte, key string) ([]T, error) { + var items []T + if err := json.Unmarshal(data, &items); err == nil { + return items, nil + } + + var wrapped map[string]json.RawMessage + if err := json.Unmarshal(data, &wrapped); err != nil { + return nil, err + } + + raw, found := wrapped[key] + if !found { + return nil, nil + } + + if err := json.Unmarshal(raw, &items); err != nil { + return nil, err + } + + return items, nil +} + +// ListForms implements portals.FormStore. +func (t *portalsTenant) ListForms(ctx context.Context) ([]portals.Form, error) { + summaries, err := pageAll(ctx, func(ctx context.Context) (*auth0.FormSummaryPage, error) { + return t.apiv3.Form.List(ctx, &managementv3.ListFormsRequestParameters{PerPage: goauth0.Int(portalsPageSize)}) + }) + if err != nil { + return nil, err + } + + listed := make([]portals.Form, 0, len(summaries)) + for _, summary := range summaries { + listed = append(listed, portals.Form{ID: summary.ID, Name: summary.Name}) + } + + return listed, nil +} + +// formNodes is the part of a form the flow ID is read from. A flow node names the flow it runs. +type formNodes struct { + Nodes []struct { + Type string `json:"type"` + Config struct { + FlowID string `json:"flow_id"` + } `json:"config"` + } `json:"nodes"` +} + +func (f formNodes) flowID() string { + for _, node := range f.Nodes { + if node.Type == "FLOW" && node.Config.FlowID != "" { + return node.Config.FlowID + } + } + + return "" +} + +// ReadForm implements portals.FormStore. It reads the form raw, because a typed read fails on a +// node type the SDK does not know. +func (t *portalsTenant) ReadForm(ctx context.Context, id string) (portals.FormDetail, error) { + data, err := t.rawJSONAt(ctx, http.MethodGet, []string{"forms", id}, "", nil) + if err != nil { + return portals.FormDetail{}, err + } + + var nodes formNodes + if err := json.Unmarshal(data, &nodes); err != nil { + return portals.FormDetail{}, fmt.Errorf("read form %q: %w", id, err) + } + + return portals.FormDetail{ID: id, FlowID: nodes.flowID()}, nil +} + +// ImportForm implements portals.FormStore. +func (t *portalsTenant) ImportForm(ctx context.Context, document any) (portals.ImportedForm, error) { + data, err := t.rawJSON(ctx, http.MethodPost, "forms/import", document) + if err := t.written(err); err != nil { + return portals.ImportedForm{}, err + } + + var imported struct { + Form struct { + ID string `json:"id"` + formNodes + } `json:"form"` + // The flows are an array, or an object keyed by the placeholder of the document. + Flows json.RawMessage `json:"flows"` + } + if err := json.Unmarshal(data, &imported); err != nil { + return portals.ImportedForm{}, fmt.Errorf("read the imported form: %w", err) + } + + if imported.Form.ID == "" { + return portals.ImportedForm{}, errors.New("the import did not return the ID of the form") + } + + flowID := firstFlowID(imported.Flows) + if flowID == "" { + flowID = imported.Form.flowID() + } + + return portals.ImportedForm{ID: imported.Form.ID, FlowID: flowID}, nil +} + +// firstFlowID returns the ID of the first flow in an array or an object of flows. +func firstFlowID(flows json.RawMessage) string { + var list []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(flows, &list); err == nil { + for _, flow := range list { + if flow.ID != "" { + return flow.ID + } + } + + return "" + } + + var keyed map[string]struct { + ID string `json:"id"` + } + if err := json.Unmarshal(flows, &keyed); err != nil { + return "" + } + + // The order of an object is not defined, so the smallest key makes the choice repeatable. + var first string + for key, flow := range keyed { + if flow.ID != "" && (first == "" || key < first) { + first = key + } + } + + return keyed[first].ID +} + +// ListPortals implements portals.PortalStore. +func (t *portalsTenant) ListPortals(ctx context.Context) ([]portals.PortalSummary, error) { + items, err := rawList[struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + }](ctx, t, "portals", "portals") + if err != nil { + return nil, err + } + + listed := make([]portals.PortalSummary, 0, len(items)) + for _, item := range items { + listed = append(listed, portals.PortalSummary{ID: item.ID, Slug: item.Slug, Name: item.Name}) + } + + return listed, nil +} + +// ReadPortal implements portals.PortalStore. +func (t *portalsTenant) ReadPortal(ctx context.Context, id string) (portals.Portal, error) { + data, err := t.rawJSONAt(ctx, http.MethodGet, []string{"portals", id}, "", nil) + if err != nil { + return portals.Portal{}, err + } + + var portal portals.Portal + if err := json.Unmarshal(data, &portal); err != nil { + return portals.Portal{}, fmt.Errorf("read portal %q: %w", id, err) + } + + return portal, nil +} + +// CreatePortal implements portals.PortalStore. +func (t *portalsTenant) CreatePortal(ctx context.Context, request portals.CreatePortalRequest) (portals.Portal, error) { + data, err := t.rawJSON(ctx, http.MethodPost, "portals", request) + if err := t.written(err); err != nil { + return portals.Portal{}, err + } + + var portal portals.Portal + if err := json.Unmarshal(data, &portal); err != nil { + return portals.Portal{}, fmt.Errorf("read the created portal: %w", err) + } + + return portal, nil +} + +// UpdatePortal implements portals.PortalStore. +func (t *portalsTenant) UpdatePortal(ctx context.Context, id string, patch portals.PortalPatch) error { + _, err := t.rawJSONAt(ctx, http.MethodPatch, []string{"portals", id}, "", patch) + + return t.written(err) +} + +// Probe implements portals.Prober. +func (t *portalsTenant) Probe(ctx context.Context, method, path string, body any) error { + _, err := t.rawJSON(ctx, method, path, body) + + return err +} diff --git a/internal/cli/universal_portals_tenant_raw_test.go b/internal/cli/universal_portals_tenant_raw_test.go new file mode 100644 index 000000000..2c05c133d --- /dev/null +++ b/internal/cli/universal_portals_tenant_raw_test.go @@ -0,0 +1,348 @@ +package cli + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" +) + +func TestPortalsTenant_Forms(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("a list follows the pages", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.handle(http.MethodGet, "forms", func(r receivedRequest) (int, string) { + page := "0" + if r.Query["page"] != nil { + page = r.Query["page"][0] + } + + switch page { + case "0": + return 200, `{"start":0,"limit":1,"total":2,"forms":[{"id":"ap_1","name":"Profile","created_at":"2026-01-01T00:00:00.000Z","updated_at":"2026-01-01T00:00:00.000Z"}]}` + case "1": + return 200, `{"start":1,"limit":1,"total":2,"forms":[{"id":"ap_2","name":"Privacy","created_at":"2026-01-01T00:00:00.000Z","updated_at":"2026-01-01T00:00:00.000Z"}]}` + default: + return 200, `{"start":2,"limit":1,"total":2,"forms":[]}` + } + }) + + forms, err := tenant.ListForms(ctx) + + require.NoError(t, err) + assert.Equal(t, []portals.Form{{ID: "ap_1", Name: "Profile"}, {ID: "ap_2", Name: "Privacy"}}, forms) + }) + + t.Run("a read takes the flow from the flow node, and ignores a node it does not know", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "forms/ap_1", 200, `{"id":"ap_1","nodes":[ + {"type":"BRAND_NEW_NODE","config":{"anything":true}}, + {"type":"STEP","config":{}}, + {"type":"FLOW","config":{"flow_id":"af_1"}}]}`) + + form, err := tenant.ReadForm(ctx, "ap_1") + + require.NoError(t, err) + assert.Equal(t, portals.FormDetail{ID: "ap_1", FlowID: "af_1"}, form) + }) + + t.Run("a form that does not exist is a 404", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "forms/ap_1", 404, `{"statusCode":404,"message":"Form not found"}`) + + _, err := tenant.ReadForm(ctx, "ap_1") + + assert.True(t, portals.IsStatus(err, http.StatusNotFound), "got %v", err) + }) + + imports := []struct { + name string + response string + wantFlowID string + wantErr string + }{ + { + name: "an import whose flows are an object keyed by placeholder", + response: `{"form":{"id":"ap_1"},"flows":{"#FLOW-1#":{"id":"af_1","name":"Flow"}}}`, + wantFlowID: "af_1", + }, + { + name: "an import whose flows are an array", + response: `{"form":{"id":"ap_1"},"flows":[{"id":"af_2"}]}`, + wantFlowID: "af_2", + }, + { + name: "an import without flows takes the flow from the form", + response: `{"form":{"id":"ap_1","nodes":[{"type":"FLOW","config":{"flow_id":"af_3"}}]}}`, + wantFlowID: "af_3", + }, + { + name: "an import that returns no form", + response: `{"flows":[]}`, + wantErr: "the import did not return the ID of the form", + }, + } + + for _, tt := range imports { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "forms/import", 200, tt.response) + + imported, err := tenant.ImportForm(ctx, map[string]any{"form": map[string]any{"name": "Profile"}}) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, portals.ImportedForm{ID: "ap_1", FlowID: tt.wantFlowID}, imported) + assert.JSONEq(t, `{"form":{"name":"Profile"}}`, server.received(http.MethodPost, "forms/import")[0].Body) + }) + } +} + +func TestPortalsTenant_Portals(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("a list reads an object and follows the pages", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.handle(http.MethodGet, "portals", func(r receivedRequest) (int, string) { + if r.Query["page"][0] == "0" { + return 200, `{"portals":` + portalsJSON(0, portalsPageSize) + `}` + } + + return 200, `{"portals":` + portalsJSON(portalsPageSize, portalsPageSize+1) + `}` + }) + + listed, err := tenant.ListPortals(ctx) + + require.NoError(t, err) + assert.Len(t, listed, portalsPageSize+1) + assert.Equal(t, portals.PortalSummary{ID: "up_0", Slug: "portal-0", Name: "Portal 0"}, listed[0]) + assert.Equal(t, []string{"100"}, server.received(http.MethodGet, "portals")[0].Query["per_page"]) + }) + + t.Run("a list reads an array", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "portals", 200, portalsJSON(0, 2)) + + listed, err := tenant.ListPortals(ctx) + + require.NoError(t, err) + assert.Len(t, listed, 2) + }) + + t.Run("a read returns the pages, the sidebar and the application", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "portals/up_1", 200, `{ + "id":"up_1","slug":"acme","name":"Acme", + "client":{"client_id":"cli_1"}, + "navigation":{"sidebar":{"components":[{"type":"sidebar:component:auth0:internal_link:v2","config":{"label":"Profile","page_slug":"profile"}}]}}, + "pages":{"default":"profile","standard":[{"title":"Profile","slug":"profile","components":[ + {"type":"page:component:auth0:structure:section","config":{"variant":"card","children":[ + {"type":"page:component:auth0:form","config":{"form_id":"ap_1"}}]}}]}]}}`) + + portal, err := tenant.ReadPortal(ctx, "up_1") + + require.NoError(t, err) + assert.Equal(t, "acme", portal.Slug) + assert.Equal(t, "cli_1", portal.Client.ClientID) + assert.Equal(t, "profile", portal.Pages.Default) + assert.Equal(t, "page:component:auth0:structure:section", portal.Pages.Standard[0].Components[0].Type) + assert.Equal(t, "page_slug", firstKey(portal.Navigation.Sidebar.Components[0].Config, "page_slug")) + }) + + t.Run("a create sends the application with its secret", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "portals", 201, `{"id":"up_1","slug":"acme","client":{"client_id":"cli_1"}}`) + + created, err := tenant.CreatePortal(ctx, portals.CreatePortalRequest{ + Slug: "acme", + Name: "Acme", + Client: &portals.PortalClient{ + ClientID: "cli_1", + ClientSecret: "s3cr3t", + TokenEndpointAuthMethod: "client_secret_post", + }, + Pages: &portals.Pages{Default: "profile", Standard: []portals.Page{{Title: "Profile", Slug: "profile"}}}, + }) + + require.NoError(t, err) + assert.Equal(t, "up_1", created.ID) + assert.JSONEq(t, `{ + "slug":"acme","name":"Acme", + "client":{"client_id":"cli_1","client_secret":"s3cr3t","token_endpoint_auth_method":"client_secret_post"}, + "pages":{"default":"profile","standard":[{"title":"Profile","slug":"profile"}]}}`, + server.received(http.MethodPost, "portals")[0].Body) + }) + + t.Run("a slug that is taken is a 409 with its code", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "portals", 409, `{"statusCode":409,"message":"Slug already in use","errorCode":"portal_conflict"}`) + + _, err := tenant.CreatePortal(ctx, portals.CreatePortalRequest{Slug: "acme", Client: &portals.PortalClient{}}) + + assert.True(t, portals.IsStatus(err, http.StatusConflict), "got %v", err) + assert.Equal(t, "portal_conflict", portals.CodeOf(err)) + }) + + t.Run("an update sends only the parts that are set", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPatch, "portals/up_1", 200, `{"id":"up_1"}`) + + err := tenant.UpdatePortal(ctx, "up_1", portals.PortalPatch{Pages: &portals.Pages{Default: "profile"}}) + + require.NoError(t, err) + assert.JSONEq(t, `{"pages":{"default":"profile"}}`, server.received(http.MethodPatch, "portals/up_1")[0].Body) + }) +} + +func portalsJSON(first, last int) string { + items := make([]string, 0, last-first) + for number := first; number < last; number++ { + id := itoa(number) + items = append(items, `{"id":"up_`+id+`","slug":"portal-`+id+`","name":"Portal `+id+`"}`) + } + + return "[" + strings.Join(items, ",") + "]" +} + +// firstKey returns key when the config has it, so a test can tell a missing config from a wrong one. +func firstKey(config map[string]any, key string) string { + if _, found := config[key]; found { + return key + } + + return "" +} + +// An id comes from the command line, so a character that means something in a URL must stay inside +// its path segment and cannot change the route or add a query. +func TestPortalsTenant_AnIdStaysOneSegment(t *testing.T) { + t.Parallel() + + const ( + id = "up_x?y/../z" + escaped = "up_x%3Fy%2F..%2Fz" + ) + + tests := []struct { + name string + path string + call func(tenant *portalsTenant) error + }{ + { + name: "reading a portal", + path: "portals/" + escaped, + call: func(tenant *portalsTenant) error { + _, err := tenant.ReadPortal(context.Background(), id) + + return err + }, + }, + { + name: "updating a portal", + path: "portals/" + escaped, + call: func(tenant *portalsTenant) error { + return tenant.UpdatePortal(context.Background(), id, portals.PortalPatch{}) + }, + }, + { + name: "reading a form", + path: "forms/" + escaped, + call: func(tenant *portalsTenant) error { + _, err := tenant.ReadForm(context.Background(), id) + + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.handle(http.MethodGet, tt.path, func(receivedRequest) (int, string) { return 404, `{"statusCode":404}` }) + server.handle(http.MethodPatch, tt.path, func(receivedRequest) (int, string) { return 404, `{"statusCode":404}` }) + + _ = tt.call(tenant) + + require.Len(t, server.requests, 1) + assert.Equal(t, tt.path, server.requests[0].Path) + assert.Empty(t, server.requests[0].Query, "the id adds no query") + }) + } +} + +func TestPortalsTenant_Probe(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("a read that works returns no error, and keeps the query", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "clients", 200, `{"clients":[]}`) + + require.NoError(t, tenant.Probe(ctx, http.MethodGet, "clients?per_page=1", nil)) + + assert.Equal(t, []string{"1"}, server.received(http.MethodGet, "clients")[0].Query["per_page"]) + }) + + t.Run("a write the API rejects is an error with its status", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "clients", 400, `{"statusCode":400,"message":"Payload validation error: 'Missing required property'","errorCode":"invalid_body"}`) + + err := tenant.Probe(ctx, http.MethodPost, "clients", map[string]any{}) + + assert.True(t, portals.IsStatus(err, http.StatusBadRequest), "got %v", err) + assert.JSONEq(t, `{}`, server.received(http.MethodPost, "clients")[0].Body) + }) + + t.Run("a scope the login lacks is a 403 that names it", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodPost, "forms/import", 403, `{"statusCode":403,"message":"Insufficient scope, expected any of: create:forms","errorCode":"insufficient_scope"}`) + + err := tenant.Probe(ctx, http.MethodPost, "forms/import", map[string]any{}) + + require.True(t, portals.IsStatus(err, http.StatusForbidden), "got %v", err) + assert.Contains(t, err.Error(), "create:forms") + }) +} diff --git a/internal/cli/universal_portals_tenant_test.go b/internal/cli/universal_portals_tenant_test.go new file mode 100644 index 000000000..c2eabf502 --- /dev/null +++ b/internal/cli/universal_portals_tenant_test.go @@ -0,0 +1,307 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management/client" + "github.com/auth0/go-auth0/v3/management/option" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/portals" +) + +const testAPIPrefix = "/api/v2/" + +// receivedRequest is a request the test server got. +type receivedRequest struct { + Method string + // Path is the escaped path below the API prefix. + Path string + Query map[string][]string + Body string +} + +func (r receivedRequest) json(t *testing.T) map[string]any { + t.Helper() + + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(r.Body), &body), "body: %q", r.Body) + + return body +} + +// portalsServer is a Management API stand-in that answers the routes a test registers and records +// every request. A request to a route nobody registered fails the test. +type portalsServer struct { + t *testing.T + mu sync.Mutex + routes map[string]func(receivedRequest) (int, string) + requests []receivedRequest +} + +// newPortalsServer starts the server and returns a tenant that talks to it through the real v1 and +// v3 clients, so the tests see the requests the SDK really sends. +func newPortalsServer(t *testing.T) (*portalsServer, *portalsTenant) { + t.Helper() + + server := &portalsServer{t: t, routes: map[string]func(receivedRequest) (int, string){}} + + httpServer := httptest.NewServer(http.HandlerFunc(server.serve)) + t.Cleanup(httpServer.Close) + + v1, err := management.New( + strings.TrimPrefix(httpServer.URL, "http://"), + management.WithStaticToken("token"), + management.WithInsecure(), + management.WithNoRetries(), + ) + require.NoError(t, err) + + v3 := managementv3.NewWithOptions( + option.WithBaseURL(httpServer.URL+"/api/v2"), + option.WithToken("token"), + option.WithMaxAttempts(1), + ) + + return server, newPortalsTenant(auth0.NewAPI(v1), auth0.NewAPIV3(v3)) +} + +func (s *portalsServer) serve(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + request := receivedRequest{ + Method: r.Method, + Path: strings.TrimPrefix(r.URL.EscapedPath(), testAPIPrefix), + Query: r.URL.Query(), + Body: string(body), + } + + s.mu.Lock() + s.requests = append(s.requests, request) + route, found := s.routes[request.Method+" "+request.Path] + s.mu.Unlock() + + if !found { + s.t.Errorf("unexpected request %s %s", request.Method, request.Path) + http.Error(w, `{"statusCode":404,"message":"no route"}`, http.StatusNotFound) + + return + } + + status, response := route(request) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, response) +} + +// reply registers a fixed answer for "METHOD path". +func (s *portalsServer) reply(method, path string, status int, body string) { + s.handle(method, path, func(receivedRequest) (int, string) { return status, body }) +} + +// handle registers a function that answers "METHOD path". +func (s *portalsServer) handle(method, path string, route func(receivedRequest) (int, string)) { + s.mu.Lock() + defer s.mu.Unlock() + + s.routes[method+" "+path] = route +} + +// received returns the requests to "METHOD path", in order. +func (s *portalsServer) received(method, path string) []receivedRequest { + s.mu.Lock() + defer s.mu.Unlock() + + var matching []receivedRequest + for _, request := range s.requests { + if request.Method == method && request.Path == path { + matching = append(matching, request) + } + } + + return matching +} + +func TestAsPortalsError(t *testing.T) { + t.Parallel() + + const scopeError = `{"statusCode":403,"error":"Forbidden","message":"Insufficient scope, expected any of: read:resource_servers","errorCode":"insufficient_scope"}` + + t.Run("a failed v1 request keeps the status, the code and the message", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "resource-servers", http.StatusForbidden, scopeError) + + _, err := tenant.ListResourceServers(context.Background()) + + var apiErr *portals.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 403, apiErr.Status) + assert.Equal(t, "insufficient_scope", apiErr.Code) + assert.Equal(t, "Insufficient scope, expected any of: read:resource_servers", apiErr.Message) + }) + + t.Run("a failed v3 request keeps the status, the code and the message", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "client-grants", http.StatusForbidden, scopeError) + + _, err := tenant.ListClientGrants(context.Background(), "client") + + var apiErr *portals.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 403, apiErr.Status) + assert.Equal(t, "insufficient_scope", apiErr.Code) + assert.Equal(t, "Insufficient scope, expected any of: read:resource_servers", apiErr.Message) + }) + + t.Run("a failed raw request keeps the status, the code and the message", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "portals/up_1", http.StatusNotFound, `{"statusCode":404,"message":"not found","errorCode":"inexistent_portal"}`) + + _, err := tenant.ReadPortal(context.Background(), "up_1") + + var apiErr *portals.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 404, apiErr.Status) + assert.Equal(t, "inexistent_portal", apiErr.Code) + assert.Equal(t, "not found", apiErr.Message) + }) + + t.Run("a body that is not JSON becomes the message", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "portals/up_1", http.StatusBadGateway, "upstream unavailable") + + _, err := tenant.ReadPortal(context.Background(), "up_1") + + var apiErr *portals.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 502, apiErr.Status) + assert.Equal(t, "upstream unavailable", apiErr.Message) + }) + + t.Run("an error that is not from the API is returned as it is", func(t *testing.T) { + t.Parallel() + + failure := errors.New("network down") + + assert.Same(t, failure, asPortalsError(failure)) + assert.NoError(t, asPortalsError(nil)) + }) +} + +func TestListPages(t *testing.T) { + t.Parallel() + + t.Run("it reads every page", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.handle(http.MethodGet, "resource-servers", func(r receivedRequest) (int, string) { + if r.Query["page"][0] == "0" { + return 200, `{"start":0,"limit":100,"total":150,"resource_servers":` + serversJSON(0, 100) + `}` + } + + return 200, `{"start":100,"limit":100,"total":150,"resource_servers":` + serversJSON(100, 150) + `}` + }) + + servers, err := tenant.ListResourceServers(context.Background()) + + require.NoError(t, err) + assert.Len(t, servers, 150) + assert.Equal(t, "https://api/0", servers[0].Identifier) + assert.Equal(t, "https://api/149", servers[149].Identifier) + + requests := server.received(http.MethodGet, "resource-servers") + require.Len(t, requests, 2) + assert.Equal(t, []string{"100"}, requests[0].Query["per_page"]) + assert.Equal(t, []string{"1"}, requests[1].Query["page"]) + }) + + t.Run("it stops a listing that never ends", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "resource-servers", 200, `{"start":0,"limit":100,"total":1000000,"resource_servers":[]}`) + + _, err := tenant.ListResourceServers(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "did not stop paging") + assert.Len(t, server.received(http.MethodGet, "resource-servers"), portalsMaxPages) + }) + + t.Run("it stops when the context ends", func(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.reply(http.MethodGet, "resource-servers", 200, `{"start":0,"limit":100,"total":1000000,"resource_servers":[]}`) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := tenant.ListResourceServers(ctx) + + assert.ErrorIs(t, err, context.Canceled) + assert.Empty(t, server.received(http.MethodGet, "resource-servers")) + }) +} + +// serversJSON returns resource servers numbered from first up to, and not including, last. +func serversJSON(first, last int) string { + items := make([]string, 0, last-first) + for number := first; number < last; number++ { + id := itoa(number) + items = append(items, `{"id":"rs_`+id+`","name":"API `+id+`","identifier":"https://api/`+id+`"}`) + } + + return "[" + strings.Join(items, ",") + "]" +} + +func itoa(number int) string { + data, _ := json.Marshal(number) + + return string(data) +} + +func TestPortalsTenant_ResourceServers(t *testing.T) { + t.Parallel() + + server, tenant := newPortalsServer(t) + server.handle(http.MethodPost, "resource-servers", func(receivedRequest) (int, string) { + return 201, `{"id":"rs_1","name":"Auth0 My Account API","identifier":"https://acme.us.auth0.com/me/"}` + }) + + created, err := tenant.CreateResourceServer(context.Background(), portals.ResourceServerSpec{ + Name: "Auth0 My Account API", + Identifier: "https://acme.us.auth0.com/me/", + SkipConsentForVerifiableFirstPartyClients: true, + TokenDialect: "rfc9068_profile", + }) + + require.NoError(t, err) + assert.Equal(t, portals.ResourceServer{ID: "rs_1", Identifier: "https://acme.us.auth0.com/me/", Name: "Auth0 My Account API"}, created) + assert.Equal(t, map[string]any{ + "name": "Auth0 My Account API", + "identifier": "https://acme.us.auth0.com/me/", + "skip_consent_for_verifiable_first_party_clients": true, + "token_dialect": "rfc9068_profile", + }, server.received(http.MethodPost, "resource-servers")[0].json(t)) +} diff --git a/internal/cli/universal_portals_terminal.go b/internal/cli/universal_portals_terminal.go new file mode 100644 index 000000000..b6b155e35 --- /dev/null +++ b/internal/cli/universal_portals_terminal.go @@ -0,0 +1,370 @@ +package cli + +import ( + "fmt" + "io" + "os" + "strings" + "time" + "unicode/utf8" + + "github.com/briandowns/spinner" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/portals" +) + +// The glyphs of the output. The left rail ties the lines of one run together, and each kind of +// line starts with its own mark. +const ( + railGlyph = "│" + stepGlyph = "◇" + successGlyph = "◆" + infoGlyph = "●" + warnGlyph = "▲" + errorGlyph = "■" + endGlyph = "└" +) + +// portalsStyle paints the output. Tests use a style that paints nothing. +type portalsStyle struct { + bold func(string) string + faint func(string) string + green func(string) string + yellow func(string) string + red func(string) string + blue func(string) string + cyan func(string) string + // The link makes the text a link to the URL where the terminal supports it. + link func(url, text string) string +} + +// ansiPortalsStyle paints with the colors of the CLI, which follow --no-color and the terminal. +func ansiPortalsStyle() portalsStyle { + return portalsStyle{ + bold: ansi.Bold, + faint: ansi.Faint, + green: ansi.Green, + yellow: ansi.Yellow, + red: ansi.BrightRed, + blue: ansi.Blue, + cyan: ansi.Cyan, + link: ansi.Hyperlink, + } +} + +// plainPortalsStyle paints nothing. +func plainPortalsStyle() portalsStyle { + plain := func(text string) string { return text } + + return portalsStyle{ + bold: plain, faint: plain, green: plain, yellow: plain, red: plain, blue: plain, cyan: plain, + link: func(_, text string) string { return text }, + } +} + +// portalsTerminal shows the progress of setup and audit on the terminal. It is the only code that +// writes the human output of the portals commands, and it writes to the stream for messages, so +// the result of a command stays alone on the stream for results. +type portalsTerminal struct { + out io.Writer + style portalsStyle + // When silent, nothing is drawn: agents and JSON get only the result. + silent bool + // When animate is set, a spinner shows while work runs, which needs a terminal. + animate bool + // The spinner already drew the blank line that comes before the next line when spinnerGap is set. + spinnerGap bool +} + +var _ portals.Reporter = (*portalsTerminal)(nil) + +func newPortalsTerminal(out io.Writer, style portalsStyle, silent, animate bool) *portalsTerminal { + return &portalsTerminal{out: out, style: style, silent: silent, animate: animate} +} + +func (t *portalsTerminal) println(line string) { + _, _ = fmt.Fprintln(t.out, line) +} + +func (t *portalsTerminal) rail() string { + return t.style.faint(railGlyph) +} + +// breakLine draws the blank line before a section, an item or a result. Right after a spinner the +// gap is already there, so no second one is drawn. +func (t *portalsTerminal) breakLine() { + if t.spinnerGap { + t.spinnerGap = false + return + } + + t.println(t.rail()) +} + +// message draws text on the rail. Every line of the text starts on the rail. +func (t *portalsTerminal) message(text string) { + for _, line := range strings.Split(text, "\n") { + t.println(t.rail() + " " + line) + } +} + +// symbol draws text after a mark. The lines after the first start on the rail. +func (t *portalsTerminal) symbol(mark, text string) { + for index, line := range strings.Split(text, "\n") { + if index == 0 { + t.println(mark + " " + line) + continue + } + + t.println(t.rail() + " " + line) + } +} + +// Banner implements portals.Reporter. It draws the title in the top border of a rounded box and the +// tenant inside, so a wrong tenant is visible from the start. +func (t *portalsTerminal) Banner(title, tenantDomain string) { + if t.silent { + return + } + + // The width is the widest of the title and the content with their padding, rounded up to an even number. + width := max(utf8.RuneCountInString(title)+2, utf8.RuneCountInString(tenantDomain)+4) + width += width % 2 + + titleSpace := width - utf8.RuneCountInString(title) + contentSpace := width - utf8.RuneCountInString(tenantDomain) + + prefix := t.rail() + " " + border := func(text string) string { return t.style.faint(text) } + + t.println(prefix + + border("╭"+strings.Repeat("─", titleSpace/2)) + + t.style.bold(title) + + border(strings.Repeat("─", titleSpace-titleSpace/2)+"╮")) + t.println(prefix + + border("│") + strings.Repeat(" ", contentSpace/2) + + t.style.cyan(tenantDomain) + + strings.Repeat(" ", contentSpace-contentSpace/2) + border("│")) + t.println(prefix + border("╰"+strings.Repeat("─", width)+"╯")) +} + +// Section implements portals.Reporter. +func (t *portalsTerminal) Section(title string) { + if t.silent { + return + } + + t.breakLine() + t.symbol(t.style.green(stepGlyph), title) +} + +// Item implements portals.Reporter. +func (t *portalsTerminal) Item(text, outcome string) { + if t.silent { + return + } + + t.breakLine() + t.message(withOutcome(text, outcome, t.style)) +} + +// Fixed implements portals.Reporter. +func (t *portalsTerminal) Fixed(text, summary string) { + if t.silent { + return + } + + t.breakLine() + t.message(withOutcome(t.style.green("✓")+" "+text, summary, t.style)) +} + +func withOutcome(text, outcome string, style portalsStyle) string { + if outcome == "" { + return text + } + + return text + " " + style.faint(outcome) +} + +// Result implements portals.Reporter. +func (t *portalsTerminal) Result(text string) { + if t.silent { + return + } + + t.breakLine() + t.symbol(t.style.green(successGlyph), text) +} + +// Info implements portals.Reporter. +func (t *portalsTerminal) Info(text string) { + if t.silent { + return + } + + t.symbol(t.style.blue(infoGlyph), text) +} + +// Warn implements portals.Reporter. +func (t *portalsTerminal) Warn(text string) { + if t.silent { + return + } + + t.symbol(t.style.yellow(warnGlyph), text) +} + +// Error implements portals.Reporter. +func (t *portalsTerminal) Error(text string) { + if t.silent { + return + } + + t.symbol(t.style.red(errorGlyph), text) +} + +// Detail implements portals.Reporter. +func (t *portalsTerminal) Detail(text string) { + if t.silent { + return + } + + t.message(t.style.faint(text)) +} + +// Reference implements portals.Reporter. Terminals that support links make the ID clickable, and +// the plain ID is shown where colors are off. +func (t *portalsTerminal) Reference(label, id, url string) { + if t.silent { + return + } + + t.message(t.style.faint(label+":") + " " + t.style.link(url, t.style.cyan(id))) +} + +// Ready implements portals.Reporter. The URL of the portal is in a note, closing the output of a +// successful setup. +func (t *portalsTerminal) Ready(url string) { + if t.silent { + return + } + + const title = "Portal URL" + + t.breakLine() + + content := "Open portal: " + url + width := max(utf8.RuneCountInString(content), utf8.RuneCountInString(title)) + 2 + blank := strings.Repeat(" ", width+2) + + t.println(t.rail()) + t.println(t.style.green(stepGlyph) + " " + title + " " + + t.style.faint(strings.Repeat("─", max(width-utf8.RuneCountInString(title)-1, 1))+"╮")) + t.println(t.rail() + blank + t.rail()) + t.println(t.rail() + " " + t.style.faint("Open portal:") + " " + t.style.link(url, t.style.cyan(url)) + + strings.Repeat(" ", width-utf8.RuneCountInString(content)) + t.rail()) + t.println(t.rail() + blank + t.rail()) + t.println(t.style.faint("├" + strings.Repeat("─", width+2) + "╯")) +} + +// Cancelled implements portals.Reporter. +func (t *portalsTerminal) Cancelled(message string) { + if t.silent { + return + } + + t.println(t.style.faint(endGlyph) + " " + t.style.red(message)) + t.println("") +} + +// Newline implements portals.Reporter. +func (t *portalsTerminal) Newline() { + if t.silent { + return + } + + t.println(t.rail()) +} + +// Table implements portals.Reporter. The widths follow the longest cell, and the first column is a +// symbol colored by what it is. +func (t *portalsTerminal) Table(headers []string, rows [][]string) { + if t.silent { + return + } + + widths := make([]int, len(headers)) + for column, header := range headers { + widths[column] = utf8.RuneCountInString(header) + + for _, row := range rows { + widths[column] = max(widths[column], utf8.RuneCountInString(row[column])) + } + } + + format := func(cells []string, paint func(cell string, column int) string) string { + padded := make([]string, 0, len(cells)) + + for column, cell := range cells { + cell += strings.Repeat(" ", widths[column]-utf8.RuneCountInString(cell)) + + if paint != nil { + cell = paint(cell, column) + } + + padded = append(padded, cell) + } + + return strings.Join(padded, " ") + } + + t.message(t.style.faint(format(headers, nil))) + + for _, row := range rows { + paintSymbol := t.style.yellow + if row[0] == "✖" { + paintSymbol = t.style.red + } + + t.message(format(row, func(cell string, column int) string { + if column == 0 { + return paintSymbol(cell) + } + + return cell + })) + } +} + +// spinnerFrames are the frames of the spinner. +var spinnerFrames = []string{"◒", "◐", "◓", "◑"} + +// Task implements portals.Reporter. The spinner line is cleared when the work ends, so the caller +// prints the result in its place. Without a terminal there is no spinner, only the work. +func (t *portalsTerminal) Task(title string, work func() error) error { + file, isFile := t.out.(*os.File) + if t.silent || !t.animate || !isFile { + return work() + } + + // The spinner starts on a line of its own, and the rail line above it is the blank line that + // comes before the next line. + t.println(t.rail()) + + running := spinner.New(spinnerFrames, 80*time.Millisecond, spinner.WithWriterFile(file)) + running.Suffix = " " + title + running.HideCursor = true + + if err := running.Color("cyan"); err != nil { + return work() + } + + running.Start() + err := work() + running.Stop() + + t.spinnerGap = true + + return err +} diff --git a/internal/cli/universal_portals_terminal_test.go b/internal/cli/universal_portals_terminal_test.go new file mode 100644 index 000000000..fc0bf97ed --- /dev/null +++ b/internal/cli/universal_portals_terminal_test.go @@ -0,0 +1,279 @@ +package cli + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// markedPortalsStyle wraps what it paints in a tag named after the style, so a test can see what was painted. +func markedPortalsStyle() portalsStyle { + mark := func(name string) func(string) string { + return func(text string) string { return "<" + name + ">" + text + "" } + } + + return portalsStyle{ + bold: mark("bold"), faint: mark("faint"), green: mark("green"), yellow: mark("yellow"), + red: mark("red"), blue: mark("blue"), cyan: mark("cyan"), + link: func(url, text string) string { return "" + text + "" }, + } +} + +func newTestTerminal(style portalsStyle, silent bool) (*portalsTerminal, *bytes.Buffer) { + var out bytes.Buffer + + return newPortalsTerminal(&out, style, silent, false), &out +} + +// drawEverything makes every kind of line, in the order of a setup. +func drawEverything(terminal *portalsTerminal) { + terminal.Banner("Universal Portals setup", "acme.us.auth0.com") + terminal.Section("Permissions") + terminal.Result("The CLI session has every permission setup needs") + terminal.Section("Resource servers") + terminal.Item(`"Auth0 My Account API"`, "already exists") + terminal.Item(`"Auth0 My Organization API"`, "created") + terminal.Result("Resource servers ready") + terminal.Section("Application") + terminal.Item(`"Acme (Universal Portals)"`, "created") + terminal.Reference("Application", "000000000000000000000000000003", "https://manage.auth0.com/x") + terminal.Warn(`The slug "acme" is already taken.`) + terminal.Info("Replace in the link with the organization ID to test with.") + terminal.Error("Missing scope") + terminal.Detail("Run the same command again to resume.") + terminal.Fixed("P003 legacy sidebar links", "converted to v2") + terminal.Table( + []string{"", "ID", "Check", "Fix"}, + [][]string{ + {"✖", "P001", "No home page", "Set the home page"}, + {"▲", "P006", "Empty page", "Add components"}, + }, + ) + terminal.Ready("https://acme.us.auth0.com/portals/acme") + terminal.Cancelled("Setup cancelled.") +} + +// The expected output is what the Clack based tool printed for the same calls, captured from it. +func TestPortalsTerminal_LooksLikeTheToolItReplaces(t *testing.T) { + t.Parallel() + + terminal, out := newTestTerminal(plainPortalsStyle(), false) + + drawEverything(terminal) + + expected := []string{ + "│ ╭─Universal Portals setup──╮", + "│ │ acme.us.auth0.com │", + "│ ╰──────────────────────────╯", + "│", + "◇ Permissions", + "│", + "◆ The CLI session has every permission setup needs", + "│", + "◇ Resource servers", + "│", + "│ \"Auth0 My Account API\" already exists", + "│", + "│ \"Auth0 My Organization API\" created", + "│", + "◆ Resource servers ready", + "│", + "◇ Application", + "│", + "│ \"Acme (Universal Portals)\" created", + "│ Application: 000000000000000000000000000003", + "▲ The slug \"acme\" is already taken.", + "● Replace in the link with the organization ID to test with.", + "■ Missing scope", + "│ Run the same command again to resume.", + "│", + "│ ✓ P003 legacy sidebar links converted to v2", + "│ ID Check Fix ", + "│ ✖ P001 No home page Set the home page", + "│ ▲ P006 Empty page Add components ", + "│", + "│", + "◇ Portal URL ──────────────────────────────────────────╮", + "│ │", + "│ Open portal: https://acme.us.auth0.com/portals/acme │", + "│ │", + "├───────────────────────────────────────────────────────╯", + "└ Setup cancelled.", + "", + "", + } + assert.Equal(t, strings.Join(expected, "\n"), out.String()) +} + +func TestPortalsTerminal_Colors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + draw func(terminal *portalsTerminal) + want string + }{ + { + name: "the title is bold and the tenant is cyan, inside a faint box", + draw: func(terminal *portalsTerminal) { terminal.Banner("Setup", "acme.us.auth0.com") }, + want: "│ ╭────────Setup─────────╮\n" + + "│ │ acme.us.auth0.com │\n" + + "│ ╰──────────────────────╯\n", + }, + { + name: "a section is a green step after a faint rail", + draw: func(terminal *portalsTerminal) { terminal.Section("Forms") }, + want: "│\n◇ Forms\n", + }, + { + name: "an outcome is faint", + draw: func(terminal *portalsTerminal) { terminal.Item(`"API"`, "created") }, + want: "│\n│ \"API\" created\n", + }, + { + name: "a line that needs attention has its own color", + draw: func(terminal *portalsTerminal) { + terminal.Info("i") + terminal.Warn("w") + terminal.Error("e") + }, + want: "● i\n▲ w\n■ e\n", + }, + { + name: "a fix has a green check", + draw: func(terminal *portalsTerminal) { terminal.Fixed("P003 links", "converted") }, + want: "│\n│ ✓ P003 links converted\n", + }, + { + name: "an ID links to its page", + draw: func(terminal *portalsTerminal) { terminal.Reference("Application", "abc", "https://x/y") }, + want: "│ Application: abc\n", + }, + { + name: "an error row is red and a warning row is yellow", + draw: func(terminal *portalsTerminal) { + terminal.Table([]string{"", "ID"}, [][]string{{"✖", "P1"}, {"▲", "P2"}}) + }, + want: "│ ID\n" + + "│ ✖ P1\n" + + "│ ▲ P2\n", + }, + { + name: "the closing line is red after a faint end mark", + draw: func(terminal *portalsTerminal) { terminal.Cancelled("Setup cancelled.") }, + want: "└ Setup cancelled.\n\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + terminal, out := newTestTerminal(markedPortalsStyle(), false) + + tt.draw(terminal) + + assert.Equal(t, tt.want, out.String()) + }) + } +} + +func TestPortalsTerminal_LinesAfterTheFirstStayOnTheRail(t *testing.T) { + t.Parallel() + + terminal, out := newTestTerminal(plainPortalsStyle(), false) + + terminal.Warn("first line\nsecond line") + terminal.Detail("one\ntwo") + + assert.Equal(t, "▲ first line\n│ second line\n│ one\n│ two\n", out.String()) +} + +func TestPortalsTerminal_SilentDrawsNothing(t *testing.T) { + t.Parallel() + + terminal, out := newTestTerminal(plainPortalsStyle(), true) + + drawEverything(terminal) + + assert.Empty(t, out.String()) +} + +func TestPortalsTerminal_Task(t *testing.T) { + t.Parallel() + + t.Run("without a terminal it runs the work and draws nothing", func(t *testing.T) { + t.Parallel() + + terminal, out := newTestTerminal(plainPortalsStyle(), false) + ran := false + + err := terminal.Task("Creating application", func() error { + ran = true + return nil + }) + + require.NoError(t, err) + assert.True(t, ran) + assert.Empty(t, out.String()) + + terminal.Result("Done") + assert.Equal(t, "│\n◆ Done\n", out.String(), "the blank line before a result is still drawn") + }) + + t.Run("it returns the error of the work", func(t *testing.T) { + t.Parallel() + + terminal, _ := newTestTerminal(plainPortalsStyle(), false) + failure := errors.New("boom") + + assert.ErrorIs(t, terminal.Task("Creating portal", func() error { return failure }), failure) + }) +} + +func TestPortalsTerminal_ASpinnerLeavesTheBlankLineBeforeTheNextLine(t *testing.T) { + t.Parallel() + + terminal, out := newTestTerminal(plainPortalsStyle(), false) + terminal.spinnerGap = true + + terminal.Result("Done") + terminal.Result("Done again") + + assert.Equal(t, "◆ Done\n│\n◆ Done again\n", out.String(), "only the first line skips the blank line") +} + +func TestAnsiPortalsStyle_KeepsTheText(t *testing.T) { + t.Parallel() + + style := ansiPortalsStyle() + + // Whether the text is painted depends on the terminal and on --no-color, but it is always there. + for name, paint := range map[string]func(string) string{ + "bold": style.bold, "faint": style.faint, "green": style.green, "yellow": style.yellow, + "red": style.red, "blue": style.blue, "cyan": style.cyan, + } { + assert.Contains(t, paint("text"), "text", name) + } + + assert.Contains(t, style.link("https://example.com", "text"), "text") +} + +func TestPortalsTerminal_Newline(t *testing.T) { + t.Parallel() + + terminal, out := newTestTerminal(plainPortalsStyle(), false) + + terminal.Newline() + terminal.Warn("after") + + assert.Equal(t, "│\n▲ after\n", out.String(), "a blank line is the bare rail, with nothing after it") + + silent, silentOut := newTestTerminal(plainPortalsStyle(), true) + silent.Newline() + assert.Empty(t, silentOut.String()) +} diff --git a/internal/cli/universal_portals_test.go b/internal/cli/universal_portals_test.go new file mode 100644 index 000000000..fcd6f3b71 --- /dev/null +++ b/internal/cli/universal_portals_test.go @@ -0,0 +1,459 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +const portalsTestTenant = "acme.us.auth0.com" + +// writeTrackingTenant is a fake tenant that says whether setup created anything in it. +type writeTrackingTenant struct { + *portalstest.Tenant +} + +func (t writeTrackingTenant) hasWritten() bool { + return len(t.Clients())+len(t.Portals())+len(t.ResourceServers())+len(t.Forms())+len(t.VaultConnections()) > 0 +} + +// setupRun is a run of the setup command on a fake tenant. +type setupRun struct { + tenant *portalstest.Tenant + messages bytes.Buffer + results bytes.Buffer + cli *cli +} + +func newSetupRun(json bool) *setupRun { + run := &setupRun{tenant: portalstest.New()} + run.cli = &cli{ + renderer: &display.Renderer{MessageWriter: &run.messages, ResultWriter: &run.results}, + tenant: portalsTestTenant, + json: json, + } + + return run +} + +func (r *setupRun) run(inputs universalPortalsSetupInputs) error { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + tracked := writeTrackingTenant{Tenant: r.tenant} + + return runUniversalPortalsSetupOn(cmd, r.cli, inputs, portalsSetupTarget{api: tracked, hasWritten: tracked.hasWritten}) +} + +var consumerInputs = universalPortalsSetupInputs{Template: "consumer", Name: "Acme Account", Slug: "acme-account"} + +func TestSetupCommand_JSON(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + + require.NoError(t, run.run(consumerInputs)) + + assert.Empty(t, run.messages.String(), "the result is alone on its stream") + + // The result has the content and the order of keys of the tool this replaces, and the + // indentation of every other command of the CLI. + var reindented bytes.Buffer + require.NoError(t, json.Indent(&reindented, run.results.Bytes(), "", " ")) + + want, err := os.ReadFile("../portals/testdata/consumer-setup.json") + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(string(want)), strings.TrimSpace(reindented.String())) +} + +func TestSetupCommand_HumanOutput(t *testing.T) { + t.Parallel() + + run := newSetupRun(false) + + require.NoError(t, run.run(consumerInputs)) + + assert.Empty(t, run.results.String(), "a person gets no JSON") + + lines := strings.Split(run.messages.String(), "\n") + assert.Equal(t, "│ ╭─Universal Portals setup──╮", lines[0]) + assert.Equal(t, "│ │ acme.us.auth0.com │", lines[1]) + assert.Contains(t, run.messages.String(), "◇ Permissions") + assert.Contains(t, run.messages.String(), "◆ The CLI session has every permission setup needs") + assert.Contains(t, run.messages.String(), `"Acme Account (Universal Portals)" created`) + assert.Contains(t, run.messages.String(), "Open portal: https://acme.us.auth0.com/portals/acme-account") + assert.Len(t, run.tenant.Portals(), 1) +} + +func TestSetupCommand_TheSlugDefaultsToTheOneOfTheName(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + + require.NoError(t, run.run(universalPortalsSetupInputs{Template: "consumer", Name: "Acme Account"})) + + assert.Equal(t, "acme-account", run.tenant.Portals()[0].Slug) +} + +func TestSetupCommand_FlagsAreChecked(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputs universalPortalsSetupInputs + wantReason string + wantText string + }{ + { + name: "a run that cannot ask needs the template and the name", + inputs: universalPortalsSetupInputs{}, + wantReason: "missing_required_flags", + wantText: "missing required flags for a non-interactive run: --template, --name", + }, + { + name: "a business portal also needs the user", + inputs: universalPortalsSetupInputs{Template: "business", Name: "Acme Admin"}, + wantReason: "missing_required_flags", + wantText: "missing required flags for a non-interactive run: --user", + }, + { + name: "a template nobody knows", + inputs: universalPortalsSetupInputs{Template: "enterprise", Name: "Acme"}, + wantReason: "invalid_input", + wantText: `unknown template "enterprise": use consumer or business`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + + err := run.run(tt.inputs) + + require.Error(t, err) + assert.Equal(t, tt.wantText, err.Error()) + assert.Equal(t, "usage", errorClass(err)) + assert.Equal(t, tt.wantReason, errorReason(err)) + assert.Empty(t, run.tenant.Calls(), "nothing is sent before the flags are checked") + }) + } +} + +func TestSetupCommand_ErrorsAreClassified(t *testing.T) { + t.Parallel() + + t.Run("a missing scope is an auth error that lists how to get it", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + run.tenant.Deny("POST portals", "Insufficient scope, expected any of: create:portals") + + err := run.run(consumerInputs) + + require.Error(t, err) + assert.Equal(t, "The CLI session is missing 1 permission(s) setup needs: create:portals. Nothing was created", err.Error()) + assert.Equal(t, "auth", errorClass(err)) + assert.Equal(t, "missing_scopes", errorReason(err)) + + var details struct { + Fixes []string `json:"fixes"` + } + require.NoError(t, json.Unmarshal(buildErrorEnvelope(err).Error.Details, &details)) + assert.Equal(t, portals.ScopeFixes([]string{"create:portals"}), details.Fixes) + }) + + t.Run("the steps that resolve an error are printed after its message", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + run.tenant.Deny("POST portals", "Insufficient scope, expected any of: create:portals") + + err := run.run(consumerInputs) + + var shown bytes.Buffer + renderErrorMessage(&display.Renderer{MessageWriter: &shown, ResultWriter: &shown}, err) + + assert.Contains(t, shown.String(), "Signed in as a user: auth0 login --scopes") + assert.Contains(t, shown.String(), "Signed in with client credentials: add create:portals to the application's Management API grant") + }) + + t.Run("a name in use is a validation error", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + run.tenant.AddClient(portals.Client{ID: "cli_manual", Secret: "x", ClientSettings: portals.ClientSettings{Name: "Acme Account (Universal Portals)"}}) + + err := run.run(consumerInputs) + + require.Error(t, err) + assert.Equal(t, "validation", errorClass(err)) + assert.Equal(t, "name_in_use", errorReason(err)) + }) + + t.Run("a slug another portal uses is a validation error that says how to resolve it", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + run.tenant.AddPortal(portals.Portal{ID: "up_other", Slug: "acme-account", Name: "Other"}) + + err := run.run(consumerInputs) + + require.Error(t, err) + assert.Equal(t, "validation", errorClass(err)) + assert.Equal(t, "slug_taken", errorReason(err)) + + var details struct { + Fixes []string `json:"fixes"` + } + require.NoError(t, json.Unmarshal(buildErrorEnvelope(err).Error.Details, &details)) + assert.Contains(t, details.Fixes, "Rerun with a different --slug") + assert.Empty(t, run.tenant.Clients(), "nothing is created before the slug is checked") + }) + + t.Run("a feature that is not enabled is reported as such", func(t *testing.T) { + t.Parallel() + + err := portalsCommandError(&portals.FeatureNotEnabledError{Tenant: portalsTestTenant}, false) + + assert.Equal(t, "auth", errorClass(err)) + assert.Equal(t, "feature_not_enabled", errorReason(err)) + }) +} + +// The class and the reason of an error are what a script reads from the JSON envelope, so each error +// of the portals package keeps its own. +func TestPortalsCommandError_EachErrorHasItsOwnReason(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + wantClass string + wantReason string + wantFixes []string + }{ + { + name: "an input the command cannot run with", + err: &portals.UsageError{Message: "bad slug"}, + wantClass: "usage", + wantReason: "invalid_input", + }, + { + name: "a user who cannot test a business portal", + err: &portals.BusinessAccessError{Message: "no access", Hints: []string{"Add the user to an organization"}}, + wantClass: "validation", + wantReason: "business_access", + wantFixes: []string{"Add the user to an organization"}, + }, + { + name: "an application that setup did not create", + err: &portals.NameInUseError{Name: "Acme"}, + wantClass: "validation", + wantReason: "name_in_use", + }, + { + name: "a slug another portal uses", + err: &portals.SlugTakenError{Slug: "acme"}, + wantClass: "validation", + wantReason: "slug_taken", + wantFixes: []string{"Rerun with a different --slug"}, + }, + { + name: "a slug taken while setup runs", + err: &portals.SlugRaceError{Slug: "acme"}, + wantClass: "validation", + wantReason: "slug_race", + }, + { + name: "a secret that needs a scope", + err: &portals.ClientSecretUnreadableError{ClientID: "cli_1"}, + wantClass: "auth", + wantReason: "missing_scopes", + wantFixes: portals.ScopeFixes([]string{"read:client_keys"}), + }, + { + name: "a check refused with no reason", + err: &portals.PermissionCheckError{Probes: []string{"create portal"}, Tenant: portalsTestTenant}, + wantClass: "auth", + wantReason: "permission_check_failed", + wantFixes: []string{"Check that Universal Portals is enabled for this tenant."}, + }, + { + name: "a feature that is not enabled", + err: &portals.FeatureNotEnabledError{Tenant: portalsTestTenant}, + wantClass: "auth", + wantReason: "feature_not_enabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := portalsCommandError(tt.err, false) + + assert.Equal(t, tt.wantClass, errorClass(err)) + assert.Equal(t, tt.wantReason, errorReason(err)) + + if tt.wantFixes == nil { + return + } + + var details struct { + Fixes []string `json:"fixes"` + } + require.NoError(t, json.Unmarshal(buildErrorEnvelope(err).Error.Details, &details)) + assert.Subset(t, details.Fixes, tt.wantFixes) + }) + } +} + +func TestSetupCommand_AFailureAfterAWriteSaysHowToResume(t *testing.T) { + t.Parallel() + + t.Run("a rerun resumes", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + run.tenant.FailNext("POST forms/import", http.StatusInternalServerError) + + err := run.run(consumerInputs) + + require.Error(t, err) + + var details struct { + Fixes []string `json:"fixes"` + } + require.NoError(t, json.Unmarshal(buildErrorEnvelope(err).Error.Details, &details)) + assert.Equal(t, []string{"Run the same command again to resume. Existing resources are reused."}, details.Fixes) + }) + + t.Run("a failure before any write has nothing to resume", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + run.tenant.FailNext("GET custom-domains/default", http.StatusInternalServerError) + + err := run.run(consumerInputs) + + require.Error(t, err) + assert.Nil(t, buildErrorEnvelope(err).Error.Details) + }) + + t.Run("a slug taken while setup runs is not resumed with the same name", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + run.tenant.FailNext("POST portals", http.StatusConflict) + + err := run.run(consumerInputs) + + require.Error(t, err) + assert.Equal(t, "slug_race", errorReason(err)) + assert.Nil(t, buildErrorEnvelope(err).Error.Details) + }) +} + +func TestReportPortalsCancelled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wrote bool + want string + }{ + { + name: "before any write", + want: "▲ Cancelled. Nothing was created.\n└ Setup cancelled.\n\n", + }, + { + name: "after a write, nothing is rolled back and a rerun resumes", + wrote: true, + want: "▲ Cancelled. Nothing was rolled back: what was already created is still in your tenant.\n" + + "▲ Run the same command again to resume. Existing resources are reused.\n└ Setup cancelled.\n\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + terminal, out := newTestTerminal(plainPortalsStyle(), false) + + reportPortalsCancelled(terminal, tt.wrote) + + assert.Equal(t, tt.want, out.String()) + }) + } +} + +func TestSetupCommand_DryRun(t *testing.T) { + t.Parallel() + + planned := func() []plannedRequest { + return []plannedRequest{{Method: "POST", Path: "portals", Body: map[string]any{"slug": "acme-account"}}} + } + + runDry := func(run *setupRun, inputs universalPortalsSetupInputs) error { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + tracked := writeTrackingTenant{Tenant: run.tenant} + + return runUniversalPortalsSetupOn(cmd, run.cli, inputs, portalsSetupTarget{api: tracked, hasWritten: tracked.hasWritten, planned: planned}) + } + + t.Run("a person is told it is a dry run, and gets a summary and no portal to open", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(false) + + require.NoError(t, runDry(run, consumerInputs)) + + assert.Contains(t, run.messages.String(), "● Dry run: nothing is created or changed. Statuses show what a real run would do.") + assert.Contains(t, run.messages.String(), "Dry run complete. 1 request(s) would change the tenant.") + assert.Contains(t, run.messages.String(), "Run the same command without --dry-run to set up the portal.") + assert.NotContains(t, run.messages.String(), "Open portal:") + assert.Empty(t, run.results.String()) + }) + + t.Run("JSON is the result with dryRun and the plan", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + + require.NoError(t, runDry(run, consumerInputs)) + + var printed map[string]any + require.NoError(t, json.Unmarshal(run.results.Bytes(), &printed)) + assert.Equal(t, true, printed["dryRun"]) + assert.Contains(t, printed, "portal") + assert.Len(t, printed["requests"], 1) + assert.Empty(t, run.messages.String()) + }) + + t.Run("the flags are checked as in a real run", func(t *testing.T) { + t.Parallel() + + run := newSetupRun(true) + + err := runDry(run, universalPortalsSetupInputs{DryRun: true}) + + require.Error(t, err) + assert.Equal(t, "missing_required_flags", errorReason(err)) + }) +} diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index 0c0d3b553..70af3c07d 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -397,52 +397,6 @@ func dashboardURLUnavailableError(tenant string) error { } } -// missingScopes returns the elements of required absent from granted. -func missingScopes(granted, required []string) []string { - have := make(map[string]struct{}, len(granted)) - for _, s := range granted { - have[s] = struct{}{} - } - var missing []string - for _, s := range required { - if _, ok := have[s]; !ok { - missing = append(missing, s) - } - } - return missing -} - -// scopesFromToken returns the granted scopes decoded from a JWT access token's -// payload, or nil if the token is malformed or carries no scope claim (callers -// then skip the pre-flight check and let the API surface a 403). -func scopesFromToken(tokenStr string) []string { - parts := strings.SplitN(tokenStr, ".", 3) - if len(parts) != 3 { - return nil - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil - } - var claims struct { - Scope string `json:"scope"` - } - if err := json.Unmarshal(payload, &claims); err != nil { - return nil - } - return strings.Fields(claims.Scope) -} - -// deriveServiceURL returns the base URL for a service (e.g. "manage") from the -// tenant domain, dropping the 2-letter region code that public envs carry. -func deriveServiceURL(service, tenantDomain string) string { - parts := strings.Split(tenantDomain, ".") - if len(parts) >= 4 && len(parts[1]) == 2 { - return "https://" + service + "." + strings.Join(parts[2:], ".") - } - return "https://" + service + "." + strings.Join(parts[1:], ".") -} - func formatManageTenantURL(tenant string, cfg *config.Config) string { if len(tenant) == 0 { return "" diff --git a/internal/portals/audit.go b/internal/portals/audit.go new file mode 100644 index 000000000..104992c2d --- /dev/null +++ b/internal/portals/audit.go @@ -0,0 +1,217 @@ +package portals + +import ( + "context" + "fmt" +) + +// AuditOptions are the choices that shape an audit. +type AuditOptions struct { + PortalID string + // Organization is the ID of the organization a business portal opens in. + Organization string + // Strict fails the audit for a warning too. + Strict bool + // Fix applies the errors the CLI can fix, without asking. + Fix bool +} + +// AuditDeps is what an audit works with. +type AuditDeps struct { + API API + Reporter Reporter + Prompter Prompter + Interactive bool + TenantDomain string +} + +// AuditResult is what an audit found. The order of the fields is the order of its JSON. +type AuditResult struct { + PortalID string `json:"portalId"` + Findings []Finding `json:"findings"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + Failed bool `json:"failed"` +} + +// AuditOutcome is an audit and the fixes made after it. +type AuditOutcome struct { + // Result is the audit after the fixes, or the first one when nothing was fixed. + Result AuditResult + // FixesFailed is the number of fixes that failed. A fix that failed fails the audit, even when the + // last audit no longer sees the error. + FixesFailed int +} + +// Failed reports whether the audit failed: it found errors, or warnings in a strict audit, or a fix failed. +func (o AuditOutcome) Failed() bool { + return o.Result.Failed || o.FixesFailed > 0 +} + +// Audit checks a portal and its application against what setup makes. It can fix the errors the CLI knows +// how to fix: all of them without asking, or the ones a person chooses. +func Audit(ctx context.Context, options AuditOptions, deps AuditDeps) (AuditOutcome, error) { + run := &auditRun{options: options, deps: deps} + + run.deps.Reporter.Banner("Universal Portals audit", deps.TenantDomain) + + report, err := run.audit(ctx) + if err != nil { + return AuditOutcome{}, err + } + + outcome := AuditOutcome{Result: report} + fixable := fixableFindings(report.Findings) + + fixes := fixDeps{api: deps.API, reporter: deps.Reporter, read: run.read} + + switch { + case options.Fix: + // Scripts: apply the errors the CLI can fix, without asking. + if len(fixable) > 0 { + outcome, err = run.fix(ctx, outcome, fixable, fixes, func(exposed []Finding) ([]Finding, error) { return exposed, nil }) + } + case deps.Interactive && len(fixable) > 0: + var selected []Finding + + if selected, err = chooseFixes(deps.Prompter, deps.Reporter, fixable); err == nil && len(selected) > 0 { + outcome, err = run.fix(ctx, outcome, selected, fixes, func(exposed []Finding) ([]Finding, error) { + deps.Reporter.Detail("These errors appeared after the fixes above.") + + return chooseFixes(deps.Prompter, deps.Reporter, exposed) + }) + } + case len(fixable) > 0: + deps.Reporter.Detail("Run with --fix to apply the errors the CLI can fix.") + } + + if err != nil { + return AuditOutcome{}, err + } + + return outcome, nil +} + +// auditRun is one run of Audit. +type auditRun struct { + options AuditOptions + deps AuditDeps +} + +func (r *auditRun) read(ctx context.Context) (snapshot, error) { + return readSnapshot(ctx, r.deps.API, r.options.PortalID, r.deps.TenantDomain, r.options.Organization) +} + +// fix applies the fixes and audits the portal again. +func (r *auditRun) fix( + ctx context.Context, + outcome AuditOutcome, + selected []Finding, + fixes fixDeps, + chooseNext func(exposed []Finding) ([]Finding, error), +) (AuditOutcome, error) { + r.deps.Reporter.Section("Fix") + + applied, err := applyFixRounds(ctx, selected, fixes, chooseNext) + if err != nil { + return AuditOutcome{}, err + } + + r.deps.Reporter.Section("Checking the portal again") + + report, err := r.audit(ctx) + if err != nil { + return AuditOutcome{}, err + } + + return AuditOutcome{Result: report, FixesFailed: outcome.FixesFailed + applied.failed}, nil +} + +// audit reads the portal, shows what it found, and returns it. +func (r *auditRun) audit(ctx context.Context) (AuditResult, error) { + reporter := r.deps.Reporter + + current, err := task(reporter, "Reading the portal", func() (snapshot, error) { return r.read(ctx) }) + if err != nil { + return AuditResult{}, err + } + + findings := auditPortal(current) + if findings == nil { + findings = []Finding{} + } + + errors := 0 + + for _, finding := range findings { + if finding.Severity == SeverityError { + errors++ + } + } + + warnings := len(findings) - errors + + reporter.Section(fmt.Sprintf("Portal %q", current.Portal.Slug)) + + if len(findings) == 0 { + reporter.Item("No problems found", "") + } else { + r.showFindings(findings) + } + + reporter.Newline() + + summary := fmt.Sprintf("%d error%s, %d warning%s", errors, plural(errors), warnings, plural(warnings)) + + switch { + case errors > 0: + reporter.Error(summary) + case warnings > 0: + reporter.Warn(summary) + default: + reporter.Item(summary, "") + } + + return AuditResult{ + PortalID: r.options.PortalID, + Findings: findings, + Errors: errors, + Warnings: warnings, + Failed: errors > 0 || (r.options.Strict && warnings > 0), + }, nil +} + +func plural(count int) string { + if count == 1 { + return "" + } + + return "s" +} + +// showFindings shows the findings in a table, and the commands for the ones the CLI cannot fix. +func (r *auditRun) showFindings(findings []Finding) { + rows := make([][]string, 0, len(findings)) + + for _, finding := range findings { + symbol := "▲" + if finding.Severity == SeverityError { + symbol = "✖" + } + + fix := finding.Message + if finding.Remedy != nil { + fix = finding.Remedy.Summary + } + + rows = append(rows, []string{symbol, finding.ID, finding.Check, fix}) + } + + r.deps.Reporter.Table([]string{"", "ID", "Check", "Fix"}, rows) + + for _, finding := range findings { + if finding.Remedy != nil && finding.Remedy.Command != "" && finding.Remedy.apply == nil { + r.deps.Reporter.Detail(fmt.Sprintf("%s manually: %s", finding.ID, finding.Remedy.Command)) + } + } +} diff --git a/internal/portals/audit_checks.go b/internal/portals/audit_checks.go new file mode 100644 index 000000000..5bd16dec7 --- /dev/null +++ b/internal/portals/audit_checks.go @@ -0,0 +1,695 @@ +package portals + +import ( + "context" + "fmt" + "slices" + "strings" +) + +// Severity tells how bad a finding is. +type Severity string + +const ( + // SeverityError means the portal or a component is broken. + SeverityError Severity = "error" + // SeverityWarning means something is degraded or differs from the setup guide. + SeverityWarning Severity = "warning" +) + +// Remedy is how to fix a finding. +type Remedy struct { + // Summary is what the fix does. + Summary string `json:"summary"` + // Command is the equivalent manual command. + Command string `json:"command,omitempty"` + + // The change itself, set when the CLI can make it. + apply func(ctx context.Context, api API) error +} + +// Finding is something the audit found. +type Finding struct { + ID string `json:"id"` + Severity Severity `json:"severity"` + Check string `json:"check"` + Message string `json:"message"` + // Subject is what the finding is about, such as a page, a form or an API. The checks that can repeat + // an ID set it, so a fix matches its finding by ID and subject and never by the text of the message. + Subject string `json:"subject,omitempty"` + Remedy *Remedy `json:"remedy,omitempty"` +} + +// canFix reports whether the CLI can fix the finding. +func (f Finding) canFix() bool { + return f.Remedy != nil && f.Remedy.apply != nil +} + +// key identifies a finding. The message can change when the portal changes (a count, a slug), so it +// never takes part in matching. +func (f Finding) key() string { + return f.ID + ":" + f.Subject +} + +// auditPortal checks a snapshot against what a portal made by setup looks like. +func auditPortal(current snapshot) []Finding { + checker := &portalAudit{current: current} + checker.pagesAndNavigation() + checker.formsAndCustomCode() + + if !checker.application() { + return checker.findings + } + + checker.refreshToken() + checker.organizationAndHeader() + checker.apisTheComponentsCall() + checker.connectionsAndSessionTransfer() + + return checker.findings +} + +// portalAudit is one audit of a snapshot. +type portalAudit struct { + current snapshot + findings []Finding +} + +func (a *portalAudit) add(finding Finding) { + a.findings = append(a.findings, finding) +} + +func (a *portalAudit) portalID() string { return a.current.Portal.ID } + +func (a *portalAudit) pageSlugs() []string { + slugs := make([]string, 0, len(a.current.Portal.pages())) + for _, page := range a.current.Portal.pages() { + slugs = append(slugs, page.Slug) + } + + return slugs +} + +func (a *portalAudit) sidebarPages() []string { + var pages []string + + for _, item := range a.current.Portal.sidebar() { + slug, _ := item.Config["page_slug"].(string) + pages = append(pages, slug) + } + + return pages +} + +func (a *portalAudit) usedComponents() []Component { + return allComponents(a.current.Portal.pages()) +} + +func (a *portalAudit) uses(needle string) bool { + return slices.ContainsFunc(a.usedComponents(), func(component Component) bool { + return strings.Contains(component.Type, needle) + }) +} + +func (a *portalAudit) pagesAndNavigation() { + portal := a.current.Portal + pages := portal.pages() + pageSlugs := a.pageSlugs() + sidebarPages := a.sidebarPages() + home := portal.homePage() + + if home == "" || !slices.Contains(pageSlugs, home) { + a.add(a.homePageFinding(home)) + } else if !slices.Contains(sidebarPages, home) { + title := home + for _, page := range pages { + if page.Slug == home { + title = page.Title + } + } + + a.add(Finding{ + ID: "P002", + Severity: SeverityWarning, + Check: "home page in navigation", + Message: fmt.Sprintf("The home page %q is not in the sidebar, so users cannot get back to it.", home), + Remedy: &Remedy{ + Summary: fmt.Sprintf("Add %q to the sidebar", title), + apply: func(ctx context.Context, api API) error { + return addSidebarLink(ctx, api, a.current, home, title, "house") + }, + }, + }) + } + + // One finding for the whole sidebar, since one fix converts all of its links. + legacy := 0 + + for _, item := range portal.sidebar() { + if isLegacyLink(item) { + legacy++ + } + } + + if legacy > 0 { + a.add(Finding{ + ID: "P003", + Severity: SeverityError, + Check: "navigation shape", + Message: fmt.Sprintf("%d sidebar link(s) use the legacy shape.", legacy), + Remedy: &Remedy{ + Summary: "Convert the sidebar links to the :v2 link types", + apply: a.convertSidebar, + }, + }) + } + + for _, item := range portal.sidebar() { + target, isText := item.Config["page_slug"].(string) + if isText && !slices.Contains(pageSlugs, target) { + a.add(Finding{ + ID: "P004", + Subject: target, + Severity: SeverityError, + Check: "sidebar link target", + Message: fmt.Sprintf("A sidebar link points to the page %q, which does not exist.", target), + Remedy: &Remedy{Summary: "Point the link at an existing page, or remove it"}, + }) + } + } + + for _, page := range pages { + a.pageFindings(page, sidebarPages) + } +} + +func (a *portalAudit) homePageFinding(home string) Finding { + pages := a.current.Portal.pages() + message := "No home page is set, so users land on an error." + + if home != "" { + message = fmt.Sprintf("The home page %q does not match any page, so users land on an error.", home) + } + + finding := Finding{ID: "P001", Severity: SeverityError, Check: "home page", Message: message} + + if len(pages) == 0 { + finding.Remedy = &Remedy{ + Summary: "Add a page and set it as the home page", + Command: fmt.Sprintf(`auth0 api patch "portals/%s" --data '{"pages":{"default":"","standard":[...]}}'`, a.portalID()), + } + + return finding + } + + first := pages[0] + finding.Remedy = &Remedy{ + Summary: fmt.Sprintf("Set %q as the home page", first.Title), + Command: fmt.Sprintf(`auth0 api patch "portals/%s" --data '{"pages":{"default":"%s","standard":[...]}}'`, a.portalID(), first.Slug), + apply: func(ctx context.Context, api API) error { + return patchPages(ctx, api, a.current, first.Slug) + }, + } + + return finding +} + +// convertSidebar converts every link of the sidebar to the :v2 types. +func (a *portalAudit) convertSidebar(ctx context.Context, api API) error { + converted := make([]SidebarComponent, 0, len(a.current.Portal.sidebar())) + for _, item := range a.current.Portal.sidebar() { + converted = append(converted, toV2Link(item)) + } + + patch := PortalPatch{Navigation: &Navigation{Sidebar: &Sidebar{Components: converted}}} + if err := api.UpdatePortal(ctx, a.portalID(), patch); err != nil { + return fmt.Errorf("convert the sidebar of portal %q: %w", a.current.Portal.Slug, err) + } + + return nil +} + +func (a *portalAudit) pageFindings(page Page, sidebarPages []string) { + if !slices.Contains(sidebarPages, page.Slug) { + a.add(Finding{ + ID: "P005", + Subject: page.Slug, + Severity: SeverityWarning, + Check: "page in navigation", + Message: fmt.Sprintf("The page %q is not in the sidebar, so users cannot reach it.", page.Title), + Remedy: &Remedy{ + Summary: fmt.Sprintf("Add %q to the sidebar", page.Title), + apply: func(ctx context.Context, api API) error { + return addSidebarLink(ctx, api, a.current, page.Slug, page.Title, "file") + }, + }, + }) + } + + if len(page.Components) == 0 { + a.add(Finding{ + ID: "P006", + Subject: page.Slug, + Severity: SeverityWarning, + Check: "empty page", + Message: fmt.Sprintf("The page %q has no components.", page.Title), + Remedy: &Remedy{Summary: "Add components to the page, or remove it"}, + }) + } +} + +func (a *portalAudit) formsAndCustomCode() { + for _, form := range a.current.Forms { + if !form.Exists { + a.add(Finding{ + ID: "P007", + Subject: form.ID, + Severity: SeverityError, + Check: "form exists", + Message: fmt.Sprintf("A form on the portal (%s) does not exist in the tenant, so its component fails.", form.ID), + Remedy: &Remedy{Summary: "Recreate the form, or remove the component from the page"}, + }) + } + } + + if customCode := a.current.Portal.CustomCode; customCode != nil && customCode.Head != "" && a.current.CustomDomain == nil { + a.add(Finding{ + ID: "P008", + Severity: SeverityWarning, + Check: "custom code", + Message: "Custom code is set, but the tenant has no custom domain, so it is never injected.", + Remedy: &Remedy{Summary: "Set up a custom domain, or remove the custom code"}, + }) + } +} + +// application checks the application of the portal, and reports false when there is none, because the +// checks that follow need it. +func (a *portalAudit) application() bool { + app := a.current.Application + if app == nil { + a.add(Finding{ + ID: "P009", + Severity: SeverityError, + Check: "application", + Message: "The portal has no application linked to it.", + Remedy: &Remedy{ + Summary: "Create the portal application with the portal defaults and link it to the portal", + apply: func(ctx context.Context, api API) error { + return createPortalApplication(ctx, api, a.current, a.uses("my_organization")) + }, + }, + }) + + return false + } + + clientID := app.ID + + if slices.Contains(app.GrantTypes, "client_credentials") { + remaining := slices.DeleteFunc(slices.Clone(app.GrantTypes), func(grant string) bool { return grant == "client_credentials" }) + + remedy := &Remedy{ + Summary: "Turn off the client_credentials grant", + apply: func(ctx context.Context, api API) error { + return updateApplication(ctx, api, clientID, ClientSettings{GrantTypes: remaining}) + }, + } + + // An update never sends an empty list, so with no grant left the change would be lost silently. + if len(remaining) == 0 { + remedy = &Remedy{ + Summary: "Replace the client_credentials grant with the grants the portal signs in with", + Command: fmt.Sprintf(`auth0 api patch "clients/%s" --data '{"grant_types":["authorization_code","refresh_token"]}'`, clientID), + } + } + + a.add(Finding{ + ID: "P010", + Severity: SeverityError, + Check: "client credentials", + Message: "The portal application has the client_credentials grant, which the setup guide says must be off.", + Remedy: remedy, + }) + } + + if app.RequireProofOfPossession != nil && *app.RequireProofOfPossession { + a.add(Finding{ + ID: "P023", + Severity: SeverityError, + Check: "DPoP", + Message: "DPoP is enabled on the portal application. Portals are confidential clients and do not use DPoP.", + Remedy: &Remedy{ + Summary: "Turn DPoP off on the portal application", + apply: func(ctx context.Context, api API) error { + return updateApplication(ctx, api, clientID, ClientSettings{RequireProofOfPossession: boolPointer(false)}) + }, + }, + }) + } + + a.managementGrants(clientID) + + callback := fmt.Sprintf("https://%s/portals/auth/callback", a.current.host()) + if !slices.Contains(app.Callbacks, callback) { + callbacks := append(slices.Clone(app.Callbacks), callback) + + a.add(Finding{ + ID: "P012", + Severity: SeverityError, + Check: "callback url", + Message: fmt.Sprintf("The callback URL %s is missing, so sign-in to the portal fails.", callback), + Remedy: &Remedy{ + Summary: "Add the portal callback URL", + apply: func(ctx context.Context, api API) error { + return updateApplication(ctx, api, clientID, ClientSettings{Callbacks: callbacks}) + }, + }, + }) + } + + return true +} + +func updateApplication(ctx context.Context, api API, clientID string, settings ClientSettings) error { + if err := api.UpdateClient(ctx, clientID, settings); err != nil { + return fmt.Errorf("update application %q: %w", clientID, err) + } + + return nil +} + +// managementGrants reports the Management API grants of the application in one finding, since one fix +// removes them all. +func (a *portalAudit) managementGrants(clientID string) { + var ids []string + + count := 0 + + for _, grant := range a.current.Grants { + if !strings.HasSuffix(grant.Audience, "/api/v2/") { + continue + } + + count++ + + if grant.ID != "" { + ids = append(ids, grant.ID) + } + } + + if count == 0 { + return + } + + remedy := &Remedy{Summary: "Remove the Management API grant from the portal application"} + + if len(ids) == count { + remedy.apply = func(ctx context.Context, api API) error { + for _, id := range ids { + if err := api.DeleteClientGrant(ctx, id); err != nil { + return fmt.Errorf("delete client grant %q: %w", id, err) + } + } + + return nil + } + } else { + remedy.Command = fmt.Sprintf(`auth0 api get "client-grants?client_id=%s"`, clientID) + } + + a.add(Finding{ + ID: "P011", + Severity: SeverityError, + Check: "management api grant", + Message: "The portal application has a Management API grant, which the setup guide says must not be there.", + Remedy: remedy, + }) +} + +func (a *portalAudit) refreshToken() { + app := *a.current.Application + refresh := app.RefreshToken + + if refresh != nil && refresh.RotationType != "" && refresh.RotationType != "non-rotating" { + a.add(Finding{ + ID: "P013", + Severity: SeverityError, + Check: "refresh token rotation", + Message: "Refresh token rotation is on, and portals do not support it. Components fail intermittently.", + Remedy: &Remedy{ + Summary: "Turn refresh token rotation off", + apply: func(ctx context.Context, api API) error { + return patchRefresh(ctx, api, app, func(token *RefreshToken) { token.RotationType = "non-rotating" }) + }, + }, + }) + } + + switch { + case refresh != nil && refresh.InfiniteIdleTokenLifetime != nil && *refresh.InfiniteIdleTokenLifetime: + a.add(Finding{ + ID: "P014", + Severity: SeverityError, + Check: "refresh token idle lifetime", + Message: "The refresh token has no idle expiry, so the portal session never ends by inactivity.", + Remedy: &Remedy{ + Summary: fmt.Sprintf("Set the idle lifetime to %d seconds", refreshTokenIdleSeconds), + apply: func(ctx context.Context, api API) error { + return patchRefresh(ctx, api, app, func(token *RefreshToken) { + token.InfiniteIdleTokenLifetime = boolPointer(false) + token.IdleTokenLifetime = intPointer(refreshTokenIdleSeconds) + }) + }, + }, + }) + case refresh != nil && (!sameInt(refresh.IdleTokenLifetime, refreshTokenIdleSeconds) || !sameInt(refresh.TokenLifetime, refreshTokenAbsoluteSeconds)): + a.add(Finding{ + ID: "P015", + Severity: SeverityWarning, + Check: "refresh token lifetimes", + Message: fmt.Sprintf( + "The refresh token lifetimes differ from the guide (%ds idle, %ds total).", + refreshTokenIdleSeconds, refreshTokenAbsoluteSeconds, + ), + Remedy: &Remedy{ + Summary: "Set the lifetimes to the guide's values", + apply: func(ctx context.Context, api API) error { + return patchRefresh(ctx, api, app, func(token *RefreshToken) { + token.IdleTokenLifetime = intPointer(refreshTokenIdleSeconds) + token.TokenLifetime = intPointer(refreshTokenAbsoluteSeconds) + }) + }, + }, + }) + } +} + +func sameInt(value *int, want int) bool { + return value != nil && *value == want +} + +func (a *portalAudit) organizationAndHeader() { + app := *a.current.Application + clientID := app.ID + + if app.OrganizationUsage != "allow" && a.uses("my_organization") { + a.add(Finding{ + ID: "P016", + Severity: SeverityError, + Check: "organization usage", + Message: "The portal has organization components, but the application does not allow organizations.", + Remedy: &Remedy{ + Summary: "Allow organizations on the application", + apply: func(ctx context.Context, api API) error { + return updateApplication(ctx, api, clientID, ClientSettings{OrganizationUsage: "allow"}) + }, + }, + }) + } + + if app.IPHeaderTrusted != nil && !*app.IPHeaderTrusted { + a.add(Finding{ + ID: "P017", + Severity: SeverityWarning, + Check: "token endpoint IP header", + Message: "The guide says to trust the token endpoint IP header, and it is off.", + Remedy: &Remedy{ + Summary: "Trust the token endpoint IP header", + apply: func(ctx context.Context, api API) error { + return updateApplication(ctx, api, clientID, ClientSettings{IPHeaderTrusted: boolPointer(true)}) + }, + }, + }) + } +} + +// apisTheComponentsCall checks the resources the components call. A component without the grant of its +// API or its refresh token policy fails when the portal loads it. +func (a *portalAudit) apisTheComponentsCall() { + app := *a.current.Application + tenant := a.current.TenantDomain + + needed := []struct { + audience string + name string + needle string + scope []string + }{ + {audience: myAccountAudience(tenant), name: "My Account API", needle: "my_account", scope: myAccountScopes}, + {audience: myOrganizationAudience(tenant), name: "My Organization API", needle: "my_organization", scope: myOrganizationScopes}, + } + + for _, api := range needed { + if !a.uses(api.needle) { + continue + } + + var usedBy []string + + for _, component := range a.usedComponents() { + if strings.Contains(component.Type, api.needle) { + usedBy = append(usedBy, componentLabel(component)) + } + } + + components := strings.Join(usedBy, ", ") + + if !slices.Contains(a.current.ResourceServers, api.audience) { + a.add(Finding{ + ID: "P018", + Subject: api.audience, + Severity: SeverityError, + Check: "resource server", + Message: fmt.Sprintf( + "The components (%s) need the %s, but the tenant has no resource server for %s.", + components, api.name, api.audience, + ), + Remedy: &Remedy{ + Summary: "Create the " + api.name + " resource server", + apply: func(ctx context.Context, client API) error { + _, err := client.CreateResourceServer(ctx, ResourceServerSpec{ + Name: "Auth0 " + api.name, + Identifier: api.audience, + SkipConsentForVerifiableFirstPartyClients: true, + TokenDialect: "rfc9068_profile", + }) + if err != nil { + return fmt.Errorf("create the %s resource server: %w", api.name, err) + } + + return nil + }, + }, + }) + } + + if !slices.ContainsFunc(a.current.Grants, func(grant ClientGrant) bool { return grant.Audience == api.audience }) { + a.add(Finding{ + ID: "P019", + Subject: api.audience, + Severity: SeverityError, + Check: "client grant", + Message: fmt.Sprintf( + "The components (%s) need the %s, but the application has no client grant for it.", + components, api.name, + ), + Remedy: &Remedy{ + Summary: "Grant the application access to the " + api.name, + apply: func(ctx context.Context, client API) error { + _, err := client.CreateClientGrant(ctx, ClientGrantSpec{ + ClientID: app.ID, + Audience: api.audience, + Scope: api.scope, + SubjectType: "user", + }) + if err != nil { + return fmt.Errorf("grant the application access to the %s: %w", api.name, err) + } + + return nil + }, + }, + }) + } + + if app.RefreshToken == nil || !slices.ContainsFunc(app.RefreshToken.Policies, func(policy RefreshTokenPolicy) bool { return policy.Audience == api.audience }) { + a.add(Finding{ + ID: "P020", + Subject: api.audience, + Severity: SeverityError, + Check: "refresh token policy", + Message: fmt.Sprintf( + "The components (%s) need the %s, but the refresh token has no policy for it, so the portal cannot get its tokens.", + components, api.name, + ), + Remedy: &Remedy{ + Summary: fmt.Sprintf("Add the %s to the multi-resource refresh token", api.name), + apply: func(ctx context.Context, client API) error { + return patchRefresh(ctx, client, app, func(token *RefreshToken) { + token.Policies = append(token.Policies, RefreshTokenPolicy{Audience: api.audience, Scope: slices.Clone(api.scope)}) + }) + }, + }, + }) + } + } +} + +func (a *portalAudit) connectionsAndSessionTransfer() { + app := *a.current.Application + + if len(a.current.ApplicationConnections) == 0 { + a.add(Finding{ + ID: "P021", + Severity: SeverityError, + Check: "connections", + Message: "No connection is enabled for the application, so nobody can sign in.", + Remedy: &Remedy{ + Summary: "Enable a connection for the application, or promote one to the tenant level", + Command: fmt.Sprintf(`auth0 api patch "connections//clients" --data '[{"client_id":"%s","status":true}]'`, app.ID), + }, + }) + } + + if organization := a.current.Organization; organization != nil { + shared := slices.ContainsFunc(organization.Connections, func(id string) bool { + return slices.ContainsFunc(a.current.ApplicationConnections, func(connection Connection) bool { return connection.ID == id }) + }) + + if !shared { + a.add(Finding{ + ID: "P022", + Severity: SeverityError, + Check: "organization connections", + Message: fmt.Sprintf( + "Organization %q has no connection in common with the application, so its members cannot sign in.", + organization.Name, + ), + Remedy: &Remedy{ + Summary: "Enable a connection that the application and the organization share", + Command: fmt.Sprintf( + `auth0 api post "organizations/%s/enabled_connections" --data '{"connection_id":"","assign_membership_on_login":false}'`, + organization.ID, + ), + }, + }) + } + } + + if app.SessionTransfer == nil || len(app.SessionTransfer.AllowedAuthenticationMethods) == 0 { + a.add(Finding{ + ID: "P024", + Severity: SeverityWarning, + Check: "session transfer", + Message: "Session transfer is not enabled, so a native app cannot open the portal with a session.", + Remedy: &Remedy{ + Summary: "Allow session transfer on the portal application", + Command: fmt.Sprintf( + `auth0 api patch "clients/%s" --data '{"session_transfer":{"allowed_authentication_methods":["query"],"enforce_device_binding":"ip"}}'`, + app.ID, + ), + }, + }) + } +} diff --git a/internal/portals/audit_checks_test.go b/internal/portals/audit_checks_test.go new file mode 100644 index 000000000..72e30a244 --- /dev/null +++ b/internal/portals/audit_checks_test.go @@ -0,0 +1,481 @@ +package portals_test + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" +) + +const ( + me = "https://acme.us.auth0.com/me/" + myOrg = "https://acme.us.auth0.com/my-org/" +) + +func sidebarLink(label, slug, icon string) portals.SidebarComponent { + return portals.SidebarComponent{ + Type: "sidebar:component:auth0:internal_link:v2", + Config: map[string]any{"label": label, "page_slug": slug, "icon": icon}, + } +} + +func section(children ...map[string]any) portals.Component { + items := make([]any, 0, len(children)) + for _, child := range children { + items = append(items, child) + } + + return portals.Component{ + Type: "page:component:auth0:structure:section", + Config: map[string]any{"variant": "card", "children": items}, + } +} + +func leaf(componentType string, config map[string]any) map[string]any { + return map[string]any{"type": componentType, "config": config} +} + +func intPointer(value int) *int { return &value } + +func boolPointer(value bool) *bool { return &value } + +// healthySnapshot is a healthy consumer portal, shaped like one created by setup. +func healthySnapshot() portals.AuditSnapshot { + return portals.AuditSnapshot{ + TenantDomain: tenantDomain, + Portal: portals.Portal{ + ID: "up_1", + Slug: "acme", + Client: &portals.PortalClient{ClientID: "cli_1"}, + Navigation: &portals.Navigation{Sidebar: &portals.Sidebar{Components: []portals.SidebarComponent{ + sidebarLink("Profile", "profile", "user"), + sidebarLink("Security", "security", "shield-check"), + }}}, + Pages: &portals.Pages{ + Default: "profile", + Standard: []portals.Page{ + {Title: "Profile", Slug: "profile", Components: []portals.Component{ + section(leaf("page:component:auth0:form", map[string]any{"form_id": "ap_1", "completion_message": "Saved"})), + }}, + {Title: "Security", Slug: "security", Components: []portals.Component{ + section(leaf("page:component:auth0:my_account:mfa_management", map[string]any{})), + }}, + }, + }, + }, + Application: &portals.Client{ID: "cli_1", ClientSettings: portals.ClientSettings{ + GrantTypes: []string{"authorization_code", "refresh_token"}, + Callbacks: []string{"https://acme.us.auth0.com/portals/auth/callback"}, + OrganizationUsage: "allow", + IPHeaderTrusted: boolPointer(true), + SessionTransfer: &portals.SessionTransfer{AllowedAuthenticationMethods: []string{"query"}}, + RefreshToken: &portals.RefreshToken{ + RotationType: "non-rotating", + IdleTokenLifetime: intPointer(3600), + TokenLifetime: intPointer(86400), + InfiniteIdleTokenLifetime: boolPointer(false), + Policies: []portals.RefreshTokenPolicy{{Audience: me, Scope: []string{"read:me:factors"}}}, + }, + }}, + Grants: []portals.ClientGrant{{Audience: me, Scope: []string{"read:me:factors"}}}, + ResourceServers: []string{me}, + Forms: []portals.FormStatus{{ID: "ap_1", Exists: true}}, + ApplicationConnections: []portals.Connection{{ID: "con_db", Name: "Username-Password-Authentication"}}, + } +} + +func idsOf(findings []portals.Finding) []string { + ids := make([]string, 0, len(findings)) + for _, finding := range findings { + ids = append(ids, finding.ID) + } + + return ids +} + +func findingNamed(t *testing.T, findings []portals.Finding, id string) portals.Finding { + t.Helper() + + index := slices.IndexFunc(findings, func(finding portals.Finding) bool { return finding.ID == id }) + require.GreaterOrEqual(t, index, 0, "%s should be reported, got %v", id, idsOf(findings)) + + return findings[index] +} + +func TestAudit_AHealthyConsumerPortalHasNoFindings(t *testing.T) { + t.Parallel() + + assert.Empty(t, portals.AuditFindings(healthySnapshot())) +} + +// One row for each rule: a healthy snapshot, one change, and the finding that change must produce. +func TestAudit_Rules(t *testing.T) { + t.Parallel() + + organizationSection := section(leaf("page:component:auth0:my_organization:details_edit", map[string]any{})) + + rules := []struct { + id string + name string + breakIt func(s *portals.AuditSnapshot) + wantSeverity portals.Severity + wantFixable bool + }{ + { + id: "P001", name: "no home page", + breakIt: func(s *portals.AuditSnapshot) { s.Portal.Pages.Default = "" }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P001", name: "a home page that matches no page", + breakIt: func(s *portals.AuditSnapshot) { s.Portal.Pages.Default = "missing" }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P002", name: "the home page missing from the sidebar", + breakIt: func(s *portals.AuditSnapshot) { s.Portal.Navigation.Sidebar.Components = nil }, + wantSeverity: portals.SeverityWarning, wantFixable: true, + }, + { + id: "P003", name: "a sidebar link in the legacy shape", + breakIt: func(s *portals.AuditSnapshot) { + s.Portal.Navigation.Sidebar.Components = []portals.SidebarComponent{ + {Type: "sidebar:component:auth0:internal_link", Config: map[string]any{"label": "Profile", "to": "profile"}}, + } + }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P004", name: "a sidebar link to a page that does not exist", + breakIt: func(s *portals.AuditSnapshot) { + s.Portal.Navigation.Sidebar.Components = append(s.Portal.Navigation.Sidebar.Components, sidebarLink("Billing", "billing", "x")) + }, + wantSeverity: portals.SeverityError, + }, + { + id: "P005", name: "a page that is not in the sidebar", + breakIt: func(s *portals.AuditSnapshot) { + s.Portal.Pages.Standard = append(s.Portal.Pages.Standard, portals.Page{Title: "Billing", Slug: "billing", Components: []portals.Component{organizationSection}}) + }, + wantSeverity: portals.SeverityWarning, wantFixable: true, + }, + { + id: "P006", name: "a page with no components", + breakIt: func(s *portals.AuditSnapshot) { + s.Portal.Pages.Standard = append(s.Portal.Pages.Standard, portals.Page{Title: "Billing", Slug: "billing"}) + s.Portal.Navigation.Sidebar.Components = append(s.Portal.Navigation.Sidebar.Components, sidebarLink("Billing", "billing", "x")) + }, + wantSeverity: portals.SeverityWarning, + }, + { + id: "P007", name: "a form of the portal that does not exist", + breakIt: func(s *portals.AuditSnapshot) { s.Forms = []portals.FormStatus{{ID: "ap_1", Exists: false}} }, + wantSeverity: portals.SeverityError, + }, + { + id: "P008", name: "custom code without a custom domain", + breakIt: func(s *portals.AuditSnapshot) { s.Portal.CustomCode = &portals.CustomCode{Head: ""} }, + wantSeverity: portals.SeverityWarning, + }, + { + id: "P009", name: "a portal with no application", + breakIt: func(s *portals.AuditSnapshot) { + s.Portal.Client = nil + s.Application = nil + }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P010", name: "client_credentials on the application", + breakIt: func(s *portals.AuditSnapshot) { + s.Application.GrantTypes = append(s.Application.GrantTypes, "client_credentials") + }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P011", name: "a Management API grant on the application", + breakIt: func(s *portals.AuditSnapshot) { + s.Grants = append(s.Grants, portals.ClientGrant{ID: "cgr_mgmt", Audience: "https://acme.us.auth0.com/api/v2/", Scope: []string{"read:users"}}) + }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P011", name: "a Management API grant whose ID is not known has no automatic fix", + breakIt: func(s *portals.AuditSnapshot) { + s.Grants = append(s.Grants, portals.ClientGrant{Audience: "https://acme.us.auth0.com/api/v2/"}) + }, + wantSeverity: portals.SeverityError, + }, + { + id: "P012", name: "a missing callback URL", + breakIt: func(s *portals.AuditSnapshot) { s.Application.Callbacks = nil }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P013", name: "a rotating refresh token", + breakIt: func(s *portals.AuditSnapshot) { s.Application.RefreshToken.RotationType = "rotating" }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P014", name: "a refresh token with no idle expiry", + breakIt: func(s *portals.AuditSnapshot) { + s.Application.RefreshToken.InfiniteIdleTokenLifetime = boolPointer(true) + }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P015", name: "refresh token lifetimes that differ from the guide", + breakIt: func(s *portals.AuditSnapshot) { s.Application.RefreshToken.IdleTokenLifetime = intPointer(7200) }, + wantSeverity: portals.SeverityWarning, wantFixable: true, + }, + { + id: "P016", name: "organization components without organization usage", + breakIt: func(s *portals.AuditSnapshot) { + s.Portal.Pages.Standard[0].Components = append(s.Portal.Pages.Standard[0].Components, organizationSection) + s.Application.OrganizationUsage = "deny" + s.ResourceServers = append(s.ResourceServers, myOrg) + s.Grants = append(s.Grants, portals.ClientGrant{Audience: myOrg}) + s.Application.RefreshToken.Policies = append(s.Application.RefreshToken.Policies, portals.RefreshTokenPolicy{Audience: myOrg}) + }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P017", name: "an untrusted token endpoint IP header", + breakIt: func(s *portals.AuditSnapshot) { s.Application.IPHeaderTrusted = boolPointer(false) }, + wantSeverity: portals.SeverityWarning, wantFixable: true, + }, + { + id: "P018", name: "a used API with no resource server", + breakIt: func(s *portals.AuditSnapshot) { s.ResourceServers = nil }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P019", name: "a used API with no client grant", + breakIt: func(s *portals.AuditSnapshot) { s.Grants = nil }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P020", name: "a used API with no refresh token policy", + breakIt: func(s *portals.AuditSnapshot) { s.Application.RefreshToken.Policies = nil }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P021", name: "no connection for the application", + breakIt: func(s *portals.AuditSnapshot) { s.ApplicationConnections = nil }, + wantSeverity: portals.SeverityError, + }, + { + id: "P022", name: "an organization with no connection in common with the application", + breakIt: func(s *portals.AuditSnapshot) { + s.Organization = &portals.SnapshotOrganization{ID: "org_1", Name: "acme", Connections: []string{"con_other"}} + }, + wantSeverity: portals.SeverityError, + }, + { + id: "P023", name: "DPoP on the application", + breakIt: func(s *portals.AuditSnapshot) { s.Application.RequireProofOfPossession = boolPointer(true) }, + wantSeverity: portals.SeverityError, wantFixable: true, + }, + { + id: "P024", name: "session transfer not enabled", + breakIt: func(s *portals.AuditSnapshot) { + s.Application.SessionTransfer = &portals.SessionTransfer{AllowedAuthenticationMethods: []string{}} + }, + wantSeverity: portals.SeverityWarning, + }, + } + + for _, rule := range rules { + t.Run(rule.id+": "+rule.name, func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + rule.breakIt(¤t) + + finding := findingNamed(t, portals.AuditFindings(current), rule.id) + + assert.Equal(t, rule.wantSeverity, finding.Severity) + assert.Equal(t, rule.wantFixable, finding.CanFix(), "whether the CLI can fix it") + assert.NotEmpty(t, finding.Check) + assert.NotEmpty(t, finding.Message) + require.NotNil(t, finding.Remedy) + assert.NotEmpty(t, finding.Remedy.Summary) + }) + } +} + +func TestAudit_TheRulesThatAreNotBrokenStayQuiet(t *testing.T) { + t.Parallel() + + t.Run("an organization that shares a connection with the application passes", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Organization = &portals.SnapshotOrganization{ID: "org_1", Name: "acme", Connections: []string{"con_db"}} + + assert.Empty(t, portals.AuditFindings(current)) + }) + + t.Run("a custom domain that is ready makes the callback the one on that domain", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.CustomDomain = &portals.CustomDomain{Domain: "auth.acme.com", Status: "ready"} + + finding := findingNamed(t, portals.AuditFindings(current), "P012") + assert.Contains(t, finding.Message, "https://auth.acme.com/portals/auth/callback") + }) + + t.Run("a custom domain that is ready but has no name is not where the portal is served", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.CustomDomain = &portals.CustomDomain{Status: "ready"} + + // Setup uses the tenant domain for it, so the callback of a portal that setup made is right. + assert.NotContains(t, idsOf(portals.AuditFindings(current)), "P012") + }) + + t.Run("custom code without a custom domain says the tenant has none", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Portal.CustomCode = &portals.CustomCode{Head: ""} + + finding := findingNamed(t, portals.AuditFindings(current), "P008") + assert.Contains(t, finding.Message, "the tenant has no custom domain") + }) + + t.Run("custom code is fine once the tenant has a custom domain", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Portal.CustomCode = &portals.CustomCode{Head: ""} + current.CustomDomain = &portals.CustomDomain{Domain: "auth.acme.com", Status: "pending_verification"} + current.Application.Callbacks = []string{"https://acme.us.auth0.com/portals/auth/callback"} + + assert.NotContains(t, idsOf(portals.AuditFindings(current)), "P008") + }) + + t.Run("without an application the checks that need it do not run", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Application = nil + current.Portal.Client = nil + current.Grants = nil + current.ApplicationConnections = nil + + assert.Equal(t, []string{"P009"}, idsOf(portals.AuditFindings(current))) + }) + + t.Run("a rotating token with no idle lifetime set at all is only a P013 and P015", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Application.RefreshToken = &portals.RefreshToken{RotationType: "rotating", Policies: current.Application.RefreshToken.Policies} + + assert.Equal(t, []string{"P013", "P015"}, idsOf(portals.AuditFindings(current))) + }) + + t.Run("an idle lifetime that never ends is P014 and not also P015", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Application.RefreshToken.InfiniteIdleTokenLifetime = boolPointer(true) + current.Application.RefreshToken.IdleTokenLifetime = nil + + assert.Equal(t, []string{"P014"}, idsOf(portals.AuditFindings(current))) + }) + + t.Run("the home page missing from the sidebar is not reported when the home page does not exist", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Portal.Pages.Default = "missing" + current.Portal.Navigation.Sidebar.Components = nil + + assert.NotContains(t, idsOf(portals.AuditFindings(current)), "P002") + }) + + t.Run("findings that share an ID are told apart by their subject", func(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Portal.Pages.Standard = append(current.Portal.Pages.Standard, + portals.Page{Slug: "terms", Title: "Terms"}, portals.Page{Slug: "faq", Title: "FAQ"}) + + var empty []portals.Finding + + for _, finding := range portals.AuditFindings(current) { + if finding.ID == "P006" { + empty = append(empty, finding) + } + } + + require.Len(t, empty, 2) + assert.NotEqual(t, empty[0].Key(), empty[1].Key()) + }) +} + +func TestAudit_ManagementAPIGrantsAreOneFinding(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Grants = append(current.Grants, + portals.ClientGrant{ID: "cgr_1", Audience: "https://acme.us.auth0.com/api/v2/"}, + portals.ClientGrant{ID: "cgr_2", Audience: "https://acme.us.auth0.com/api/v2/"}, + ) + + var found int + + for _, finding := range portals.AuditFindings(current) { + if finding.ID == "P011" { + found++ + } + } + + assert.Equal(t, 1, found) +} + +// Turning client_credentials off would leave the application with no grant at all, and an update +// cannot send an empty list, so the fix would report success and change nothing. +func TestAudit_ClientCredentialsAsTheOnlyGrantIsFixedByHand(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Application.GrantTypes = []string{"client_credentials"} + + finding := findingNamed(t, portals.AuditFindings(current), "P010") + + assert.False(t, finding.CanFix(), "the CLI has no grant to leave the application with") + require.NotNil(t, finding.Remedy) + assert.Equal(t, + `auth0 api patch "clients/cli_1" --data '{"grant_types":["authorization_code","refresh_token"]}'`, + finding.Remedy.Command, + ) +} + +func TestAudit_FindingsAreJSON(t *testing.T) { + t.Parallel() + + current := healthySnapshot() + current.Application.GrantTypes = append(current.Application.GrantTypes, "client_credentials") + current.ApplicationConnections = nil + + findings := portals.AuditFindings(current) + + assert.Equal(t, []string{"P010", "P021"}, idsOf(findings)) + assert.JSONEq(t, `[ + {"id":"P010","severity":"error","check":"client credentials", + "message":"The portal application has the client_credentials grant, which the setup guide says must be off.", + "remedy":{"summary":"Turn off the client_credentials grant"}}, + {"id":"P021","severity":"error","check":"connections", + "message":"No connection is enabled for the application, so nobody can sign in.", + "remedy":{"summary":"Enable a connection for the application, or promote one to the tenant level", + "command":"auth0 api patch \"connections//clients\" --data '[{\"client_id\":\"cli_1\",\"status\":true}]'"}} + ]`, mustJSON(t, findings)) +} diff --git a/internal/portals/audit_fix.go b/internal/portals/audit_fix.go new file mode 100644 index 000000000..80f4bca85 --- /dev/null +++ b/internal/portals/audit_fix.go @@ -0,0 +1,208 @@ +package portals + +import ( + "context" + "fmt" + "slices" + "strings" +) + +// A fix can expose errors the first audit could not see: a new application has no client grant, so +// linking it makes P019 visible. After each pass, the next errors are chosen, and none ends the run. A +// finding is attempted once, and the passes stop after maxFixRounds so a fix that keeps breaking another +// cannot loop. +const maxFixRounds = 3 + +// fixDeps is what the fixes work with. +type fixDeps struct { + api API + reporter Reporter + // The portal is read again before each fix, so a fix never works on a stale copy and never + // overwrites a change made by an earlier fix. + read func(ctx context.Context) (snapshot, error) +} + +// fixResult is what a pass of fixes did. +type fixResult struct { + applied int + failed int + // The fixes that were no longer needed by the time they ran. + alreadyFixed int + // The scopes the failed fixes needed, so the fix for them is shown once at the end. + missingScopes []string +} + +// fixableFindings returns the findings the CLI fixes. Only errors are fixed: warnings may be +// intentional, so they stay in the report and are never changed. +func fixableFindings(findings []Finding) []Finding { + return slices.DeleteFunc(slices.Clone(findings), func(finding Finding) bool { + return finding.Severity != SeverityError || !finding.canFix() + }) +} + +// applyFixes applies the fixes of the findings one by one. +func applyFixes(ctx context.Context, selected []Finding, deps fixDeps) (fixResult, error) { + var result fixResult + + for _, finding := range selected { + current, err := deps.read(ctx) + if err != nil { + return result, err + } + + fresh, found := findFinding(auditPortal(current), finding) + if !found || !fresh.canFix() { + result.alreadyFixed++ + + deps.reporter.Item(fmt.Sprintf("%s %s", finding.ID, finding.Check), "already fixed") + + continue + } + + // Each fix shows the finding it resolves, with a spinner while it runs. + label := fmt.Sprintf("%s %s", fresh.ID, fresh.Check) + + err = deps.reporter.Task("Fixing "+fresh.Check, func() error { return fresh.Remedy.apply(ctx, deps.api) }) + if err != nil { + // A cancel or an interrupt ends the audit, and is not a fix that failed. + if ctx.Err() != nil { + return result, ctx.Err() + } + + result.failed++ + + deps.reporter.Error(fmt.Sprintf("%s: %s", label, err)) + + if gap, isGap := ParseScopeGap(label, err); isGap { + result.missingScopes = append(result.missingScopes, gap.Required()...) + } + + continue + } + + result.applied++ + + deps.reporter.Fixed(label, fresh.Remedy.Summary) + } + + return result, nil +} + +func findFinding(findings []Finding, wanted Finding) (Finding, bool) { + index := slices.IndexFunc(findings, func(candidate Finding) bool { return candidate.key() == wanted.key() }) + if index < 0 { + return Finding{}, false + } + + return findings[index], true +} + +// applyFixRounds applies the fixes, and then those of the errors they exposed that chooseNext picks. +func applyFixRounds( + ctx context.Context, + selected []Finding, + deps fixDeps, + chooseNext func(exposed []Finding) ([]Finding, error), +) (fixResult, error) { + var total fixResult + + attempted := map[string]bool{} + pending := selected + + for round := 0; round < maxFixRounds && len(pending) > 0; round++ { + for _, finding := range pending { + attempted[finding.key()] = true + } + + result, err := applyFixes(ctx, pending, deps) + + total.applied += result.applied + total.failed += result.failed + total.alreadyFixed += result.alreadyFixed + total.missingScopes = append(total.missingScopes, result.missingScopes...) + + if err != nil { + return total, err + } + + current, err := deps.read(ctx) + if err != nil { + return total, err + } + + exposed := fixableFindings(auditPortal(current)) + exposed = slices.DeleteFunc(exposed, func(finding Finding) bool { return attempted[finding.key()] }) + + pending = nil + + if len(exposed) > 0 { + if pending, err = chooseNext(exposed); err != nil { + return total, err + } + } + } + + slices.Sort(total.missingScopes) + total.missingScopes = slices.Compact(total.missingScopes) + + if total.failed > 0 { + reportFailedFixes(deps.reporter, total) + } + + return total, nil +} + +// reportFailedFixes sums up the fixes that failed, with how to get the scopes they needed, once. +func reportFailedFixes(reporter Reporter, total fixResult) { + missing := "" + if len(total.missingScopes) > 0 { + missing = ", missing: " + strings.Join(total.missingScopes, ", ") + } + + reporter.Error(fmt.Sprintf("%d fix(es) failed%s", total.failed, missing)) + + if len(total.missingScopes) > 0 { + for _, fix := range ScopeFixes(total.missingScopes) { + reporter.Detail(fix) + } + } +} + +// chooseFixes asks what to do with the errors: fix every one, choose the fixes, or skip. The errors +// start checked in the list. +func chooseFixes(prompter Prompter, reporter Reporter, errors []Finding) ([]Finding, error) { + reporter.Detail("Warnings are not changed. They may be intentional.") + + picked, err := prompter.Choose("What do you want to fix?", []Choice{ + {Label: "Fix all errors", Hint: "recommended"}, + {Label: "Choose errors to fix", Hint: "every error starts checked"}, + {Label: "Skip", Hint: "leave the portal as it is"}, + }) + if err != nil { + return nil, err + } + + switch picked { + case 0: + return errors, nil + case 1: + items := make([]ChecklistItem, 0, len(errors)) + for _, finding := range errors { + items = append(items, ChecklistItem{Label: fmt.Sprintf("%s %s", finding.ID, finding.Remedy.Summary), Checked: true}) + } + + indexes, err := prompter.Checklist("Errors to fix", items) + if err != nil { + return nil, err + } + + chosen := make([]Finding, 0, len(indexes)) + for _, index := range indexes { + chosen = append(chosen, errors[index]) + } + + return chosen, nil + default: + return nil, nil + } +} diff --git a/internal/portals/audit_fix_test.go b/internal/portals/audit_fix_test.go new file mode 100644 index 000000000..b68d5eaf1 --- /dev/null +++ b/internal/portals/audit_fix_test.go @@ -0,0 +1,742 @@ +package portals_test + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +// fixOnly applies the fix of the finding with the ID, which must be reported and fixable. +func (p auditedPortal) fixOnly(t *testing.T, id string) portals.FixSummary { + t.Helper() + + finding := findingNamed(t, p.findings(t), id) + require.True(t, finding.CanFix(), "%s should have an automatic fix", id) + + summary, err := portals.FixFindings(context.Background(), p.tenant, &portalstest.Reporter{}, tenantDomain, p.portalID, []portals.Finding{finding}) + require.NoError(t, err) + + return summary +} + +func (p auditedPortal) portal(t *testing.T) portals.Portal { + t.Helper() + + portal, err := p.tenant.ReadPortal(context.Background(), p.portalID) + require.NoError(t, err) + + return portal +} + +func (p auditedPortal) client(t *testing.T, id string) portals.Client { + t.Helper() + + client, err := p.tenant.ReadClient(context.Background(), id) + require.NoError(t, err) + + return client +} + +func TestFix_Remedies(t *testing.T) { + t.Parallel() + + t.Run("P003: converting a legacy sidebar link leaves only :v2 links", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.setSidebar(legacyLink("profile")) + + portal.fixOnly(t, "P003") + + links := portal.portal(t).Navigation.Sidebar.Components + assert.Equal(t, []portals.SidebarComponent{sidebarLink("Profile", "profile", "user")}, links) + }) + + t.Run("P003: an external legacy link becomes an external :v2 link", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.setSidebar( + legacyLink("profile"), + portals.SidebarComponent{Type: "sidebar:component:auth0:external_link", Config: map[string]any{"label": "Help", "url": "https://help.acme.com", "icon": "life-buoy"}}, + ) + + portal.fixOnly(t, "P003") + + assert.Equal(t, portals.SidebarComponent{ + Type: "sidebar:component:auth0:external_link:v2", + Config: map[string]any{"label": "Help", "href": "https://help.acme.com", "icon": "life-buoy"}, + }, portal.portal(t).Navigation.Sidebar.Components[1]) + }) + + t.Run("P002: adding the home page to the sidebar makes it reachable", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.setSidebar() + + portal.fixOnly(t, "P002") + + var slugs []string + for _, item := range portal.portal(t).Navigation.Sidebar.Components { + slugs = append(slugs, item.Config["page_slug"].(string)) + } + + assert.Contains(t, slugs, "profile") + }) + + t.Run("P001: setting the first page as the home page keeps the pages as they were", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + before := portal.portal(t).Pages.Standard + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Pages.Default = "" }) + + portal.fixOnly(t, "P001") + + after := portal.portal(t).Pages + assert.Equal(t, "profile", after.Default) + assert.Equal(t, before, after.Standard) + }) + + t.Run("P011: removing the Management API grant deletes it from the application", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.AddGrant(portals.ClientGrant{ID: "cgr_mgmt", ClientID: portal.clientID, Audience: "https://acme.us.auth0.com/api/v2/", Scope: []string{"read:users"}}) + + portal.fixOnly(t, "P011") + + for _, grant := range portal.tenant.Grants() { + assert.NotEqual(t, "cgr_mgmt", grant.ID) + } + }) + + t.Run("P010: turning off client credentials keeps the other grants", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { + client.GrantTypes = append(client.GrantTypes, "client_credentials") + }) + + portal.fixOnly(t, "P010") + + grants := portal.client(t, portal.clientID).GrantTypes + assert.NotContains(t, grants, "client_credentials") + assert.Contains(t, grants, "authorization_code") + assert.Contains(t, grants, "refresh_token") + }) + + t.Run("P023: turning DPoP off on the application", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.RequireProofOfPossession = boolPointer(true) }) + + portal.fixOnly(t, "P023") + + assert.False(t, *portal.client(t, portal.clientID).RequireProofOfPossession) + }) + + t.Run("P012: adding the callback keeps the ones that were there", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.Callbacks = []string{"https://other/callback"} }) + + portal.fixOnly(t, "P012") + + assert.Equal(t, + []string{"https://other/callback", "https://acme.us.auth0.com/portals/auth/callback"}, + portal.client(t, portal.clientID).Callbacks, + ) + }) + + t.Run("P013: turning rotation off keeps the policies and the lifetimes", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.RefreshToken.RotationType = "rotating" }) + + portal.fixOnly(t, "P013") + + token := portal.client(t, portal.clientID).RefreshToken + assert.Equal(t, "non-rotating", token.RotationType) + assert.Equal(t, 3600, *token.IdleTokenLifetime) + assert.Equal(t, 86400, *token.TokenLifetime) + require.Len(t, token.Policies, 1) + assert.Equal(t, me, token.Policies[0].Audience) + }) + + t.Run("P014: giving the refresh token an idle expiry", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { + client.RefreshToken.InfiniteIdleTokenLifetime = boolPointer(true) + client.RefreshToken.IdleTokenLifetime = nil + }) + + portal.fixOnly(t, "P014") + + token := portal.client(t, portal.clientID).RefreshToken + assert.False(t, *token.InfiniteIdleTokenLifetime) + assert.Equal(t, 3600, *token.IdleTokenLifetime) + }) + + t.Run("P020: adding the policy of an API keeps the one it has", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.RefreshToken.Policies = nil }) + + portal.fixOnly(t, "P020") + + policies := portal.client(t, portal.clientID).RefreshToken.Policies + require.Len(t, policies, 1) + assert.Equal(t, me, policies[0].Audience) + assert.Equal(t, portals.MyAccountScopes(), policies[0].Scope) + }) + + t.Run("P019: granting access to an API a component calls", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + for _, grant := range portal.tenant.Grants() { + if grant.ClientID == portal.clientID && grant.Audience == me { + portal.tenant.DeleteGrant(grant.ID) + } + } + + portal.fixOnly(t, "P019") + + found := false + + for _, grant := range portal.tenant.Grants() { + if grant.ClientID == portal.clientID && grant.Audience == me { + found = true + + assert.Equal(t, portals.MyAccountScopes(), grant.Scope) + } + } + + assert.True(t, found) + }) + + t.Run("P018: creating the resource server of an API a component calls", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.AddResourceServer(portals.ResourceServer{ID: "rs_x", Identifier: "https://elsewhere/"}) + + for _, server := range portal.tenant.ResourceServers() { + if server.Identifier == me { + portal.tenant.DeleteResourceServer(server.ID) + } + } + + portal.fixOnly(t, "P018") + + identifiers := []string{} + for _, server := range portal.tenant.ResourceServers() { + identifiers = append(identifiers, server.Identifier) + } + + assert.Contains(t, identifiers, me) + }) + + t.Run("P016: allowing organizations on the application", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { + p.Pages.Standard[1].Components = append(p.Pages.Standard[1].Components, section(leaf("page:component:auth0:my_organization:details_edit", map[string]any{}))) + }) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.OrganizationUsage = "deny" }) + // The organization components also need their API, which is fixed on its own. + require.Contains(t, idsOf(portal.findings(t)), "P016") + + portal.fixOnly(t, "P016") + + assert.Equal(t, "allow", portal.client(t, portal.clientID).OrganizationUsage) + }) +} + +// patchRecorder records what is sent to link an application to a portal. +type patchRecorder struct { + *portalstest.Tenant + patches []portals.PortalPatch +} + +func (r *patchRecorder) UpdatePortal(ctx context.Context, id string, patch portals.PortalPatch) error { + r.patches = append(r.patches, patch) + + return r.Tenant.UpdatePortal(ctx, id, patch) +} + +func TestFix_AMissingApplication(t *testing.T) { + t.Parallel() + + t.Run("P009: the fix creates the application and links it, with the secret", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Client = nil }) + portal.tenant.DeleteClient(portal.clientID) + + recorder := &patchRecorder{Tenant: portal.tenant} + finding := findingNamed(t, portal.findings(t), "P009") + + _, err := portals.FixFindings(context.Background(), recorder, &portalstest.Reporter{}, tenantDomain, portal.portalID, []portals.Finding{finding}) + require.NoError(t, err) + + require.Len(t, recorder.patches, 1) + link := recorder.patches[0].Client + require.NotNil(t, link) + assert.NotEqual(t, portal.clientID, link.ClientID) + assert.NotEmpty(t, link.ClientSecret, "the secret is sent with the link") + assert.Equal(t, "client_secret_post", link.TokenEndpointAuthMethod) + assert.Equal(t, link.ClientID, portal.portal(t).Client.ClientID, "the portal points at the new application") + assert.Len(t, portal.tenant.Clients(), 2, "the new application and the forms vault") + }) + + t.Run("P009: a link that fails after the application was created does not create a second one on rerun", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Client = nil }) + portal.tenant.DeleteClient(portal.clientID) + portal.tenant.FailNext("PATCH portals/"+portal.portalID, http.StatusInternalServerError) + + first := findingNamed(t, portal.findings(t), "P009") + failed, err := portals.FixFindings(context.Background(), portal.tenant, &portalstest.Reporter{}, tenantDomain, portal.portalID, []portals.Finding{first}) + require.NoError(t, err) + assert.Equal(t, 1, failed.Failed) + assert.Nil(t, portal.portal(t).Client, "the first link failed") + + portal.fixOnly(t, "P009") + + assert.Len(t, portal.tenant.Clients(), 2, "one application in total, besides the forms vault") + assert.NotNil(t, portal.portal(t).Client) + }) + + t.Run("P009: a portal with organization components gets the business defaults", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { + p.Client = nil + p.Pages.Standard[1].Components = append(p.Pages.Standard[1].Components, section(leaf("page:component:auth0:my_organization:details_edit", map[string]any{}))) + }) + portal.tenant.DeleteClient(portal.clientID) + + portal.fixOnly(t, "P009") + + created := portal.client(t, portal.portal(t).Client.ClientID) + audiences := []string{} + for _, policy := range created.RefreshToken.Policies { + audiences = append(audiences, policy.Audience) + } + + assert.ElementsMatch(t, []string{me, myOrg}, audiences) + }) +} + +func TestFix_Rounds(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("applying the errors repairs a home page, a missing refresh policy, and rotation", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Pages.Default = "" }) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { + client.RefreshToken.RotationType = "rotating" + client.RefreshToken.Policies = nil + }) + + summary, err := portals.FixFindings(ctx, portal.tenant, &portalstest.Reporter{}, tenantDomain, portal.portalID, portals.FixableFindings(portal.findings(t))) + require.NoError(t, err) + + assert.GreaterOrEqual(t, summary.Applied, 3) + + outcome, _ := portal.audit(t, portals.AuditOptions{}) + assert.Zero(t, outcome.Result.Errors, "%v", idsOf(outcome.Result.Findings)) + }) + + t.Run("a fix that is already in place is skipped, not applied twice", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Pages.Default = "" }) + stale := findingNamed(t, portal.findings(t), "P001") + // The home page is set by hand meanwhile. + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Pages.Default = "profile" }) + + reporter := &portalstest.Reporter{} + summary, err := portals.FixFindings(ctx, portal.tenant, reporter, tenantDomain, portal.portalID, []portals.Finding{stale}) + + require.NoError(t, err) + assert.Equal(t, 1, summary.AlreadyFixed) + assert.Zero(t, summary.Applied) + assert.True(t, reporter.Contains("item", "P001 home page | already fixed")) + }) + + t.Run("a fix still applies when the message of the finding changed after the audit", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.setSidebar(legacyLink("profile")) + finding := findingNamed(t, portal.findings(t), "P003") + + // A second legacy link changes the count in the message, and not the problem. + portal.setSidebar(legacyLink("profile"), legacyLink("account")) + summary, err := portals.FixFindings(ctx, portal.tenant, &portalstest.Reporter{}, tenantDomain, portal.portalID, []portals.Finding{finding}) + + require.NoError(t, err) + assert.Equal(t, 1, summary.Applied) + assert.Zero(t, summary.AlreadyFixed) + }) + + t.Run("warnings are reported but never offered for fixing", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.IPHeaderTrusted = boolPointer(false) }) + + findings := portal.findings(t) + + assert.Empty(t, portals.FixableFindings(findings)) + assert.Equal(t, portals.SeverityWarning, findingNamed(t, findings, "P017").Severity) + }) + + t.Run("the errors a fix exposes are fixed in the next round", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Client = nil }) + portal.tenant.DeleteClient(portal.clientID) + + var exposed []string + + summary, err := portals.FixFindingRounds( + ctx, portal.tenant, &portalstest.Reporter{}, tenantDomain, portal.portalID, + portals.FixableFindings(portal.findings(t)), + func(next []portals.Finding) ([]portals.Finding, error) { + exposed = append(exposed, idsOf(next)...) + return next, nil + }, + ) + + require.NoError(t, err) + assert.Equal(t, []string{"P019"}, exposed, "the new application has no grant") + assert.Equal(t, 2, summary.Applied) + assert.NotContains(t, idsOf(portal.findings(t)), "P019") + }) + + t.Run("the errors a fix exposes are left alone when the person declines them", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Client = nil }) + portal.tenant.DeleteClient(portal.clientID) + + _, err := portals.FixFindingRounds( + ctx, portal.tenant, &portalstest.Reporter{}, tenantDomain, portal.portalID, + portals.FixableFindings(portal.findings(t)), + func([]portals.Finding) ([]portals.Finding, error) { return nil, nil }, + ) + + require.NoError(t, err) + assert.Contains(t, idsOf(portal.findings(t)), "P019", "the exposed grant error is still reported") + }) +} + +func TestFix_AFixThatFails(t *testing.T) { + t.Parallel() + + t.Run("fixes that fail for a missing scope are summed up once, with the login command", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Pages.Default = "" }) + portal.tenant.Deny("PATCH portals/"+portal.portalID, "Insufficient scope, expected any of: update:portals") + reporter := &portalstest.Reporter{} + + summary, err := portals.FixFindingRounds( + context.Background(), portal.tenant, reporter, tenantDomain, portal.portalID, + portals.FixableFindings(portal.findings(t)), + func([]portals.Finding) ([]portals.Finding, error) { return nil, nil }, + ) + + require.NoError(t, err) + assert.Positive(t, summary.Failed) + assert.Equal(t, []string{"update:portals"}, summary.MissingScopes) + assert.True(t, reporter.Contains("error", "fix(es) failed, missing: update:portals")) + assert.True(t, reporter.Contains("detail", "Signed in as a user: auth0 login --scopes ")) + assert.True(t, reporter.Contains("detail", "update:portals")) + }) + + t.Run("a failure that is not a scope is shown with the fix it belongs to, and the rest continue", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Pages.Default = "" }) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.RefreshToken.RotationType = "rotating" }) + portal.tenant.FailNext("PATCH portals/"+portal.portalID, http.StatusInternalServerError) + reporter := &portalstest.Reporter{} + + summary, err := portals.FixFindings( + context.Background(), portal.tenant, reporter, tenantDomain, portal.portalID, + portals.FixableFindings(portal.findings(t)), + ) + + require.NoError(t, err) + assert.Equal(t, 1, summary.Failed) + assert.Equal(t, 1, summary.Applied, "the fix of the rotation still ran") + assert.True(t, reporter.Contains("error", "P001 home page: ")) + assert.Empty(t, summary.MissingScopes) + }) + + t.Run("a fix that failed fails the audit, even when the audit after it sees no error", func(t *testing.T) { + t.Parallel() + + outcome := portals.AuditOutcome{Result: portals.AuditResult{Failed: false}, FixesFailed: 1} + + assert.True(t, outcome.Failed()) + assert.False(t, portals.AuditOutcome{}.Failed()) + }) +} + +func TestAudit_Fixing(t *testing.T) { + t.Parallel() + + t.Run("--fix applies every error without asking, and audits again", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + + outcome, reporter := portal.audit(t, portals.AuditOptions{Fix: true}) + + assert.Empty(t, outcome.Result.Findings) + assert.False(t, outcome.Failed()) + assert.True(t, reporter.Contains("section", "Fix")) + assert.True(t, reporter.Contains("section", "Checking the portal again")) + assert.True(t, reporter.Contains("fixed", "P003 navigation shape | Convert the sidebar links to the :v2 link types")) + }) + + t.Run("--fix with nothing to fix changes nothing", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + before := len(portal.tenant.Writes()) + + _, reporter := portal.audit(t, portals.AuditOptions{Fix: true}) + + assert.Len(t, portal.tenant.Writes(), before) + assert.False(t, reporter.Contains("section", "Fix")) + }) + + t.Run("without --fix and without a terminal it says how to fix, and changes nothing", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + + outcome, reporter := portal.audit(t, portals.AuditOptions{}) + + assert.True(t, outcome.Failed()) + assert.True(t, reporter.Contains("detail", "Run with --fix to apply the errors the CLI can fix.")) + assert.Equal(t, "sidebar:component:auth0:internal_link", portal.portal(t).Navigation.Sidebar.Components[0].Type) + }) + + t.Run("a fix that failed fails the run", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + portal.tenant.FailNext("PATCH portals/"+portal.portalID, http.StatusInternalServerError) + + outcome, _ := portal.audit(t, portals.AuditOptions{Fix: true}) + + assert.Equal(t, 1, outcome.FixesFailed) + assert.True(t, outcome.Failed()) + }) +} + +// fixMenu answers the fix menu by position (0 fix all, 1 choose, 2 skip), and keeps the checked +// errors whose IDs are listed. +func fixMenu(position int, keep ...string) *portalstest.Prompter { + return &portalstest.Prompter{ + ChooseFunc: func(string, []portals.Choice) (int, error) { return position, nil }, + ChecklistFunc: func(_ string, items []portals.ChecklistItem) ([]int, error) { + var picked []int + + for index, item := range items { + if len(keep) == 0 || containsPrefix(keep, item.Label) { + picked = append(picked, index) + } + } + + return picked, nil + }, + } +} + +func containsPrefix(ids []string, label string) bool { + for _, id := range ids { + if strings.HasPrefix(label, id+" ") { + return true + } + } + + return false +} + +func (p auditedPortal) auditInTerminal(t *testing.T, prompter portals.Prompter) (portals.AuditOutcome, *portalstest.Reporter, error) { + t.Helper() + + reporter := &portalstest.Reporter{} + outcome, err := portals.Audit(context.Background(), portals.AuditOptions{PortalID: p.portalID}, portals.AuditDeps{ + API: p.tenant, Reporter: reporter, Prompter: prompter, Interactive: true, TenantDomain: tenantDomain, + }) + + return outcome, reporter, err +} + +func TestAudit_TheFixMenu(t *testing.T) { + t.Parallel() + + t.Run("fix all applies every error and the portal passes", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + + outcome, reporter, err := portal.auditInTerminal(t, fixMenu(0)) + + require.NoError(t, err) + assert.False(t, outcome.Failed()) + assert.True(t, reporter.Contains("detail", "Warnings are not changed. They may be intentional.")) + + for _, link := range portal.portal(t).Navigation.Sidebar.Components { + assert.True(t, strings.HasSuffix(link.Type, ":v2")) + } + }) + + t.Run("choosing errors applies only the checked ones", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Client = nil }) + portal.tenant.DeleteClient(portal.clientID) + clientsBefore := len(portal.tenant.Clients()) + + outcome, _, err := portal.auditInTerminal(t, fixMenu(1, "P003")) + + require.NoError(t, err) + assert.True(t, outcome.Failed(), "the unchecked application error is still reported") + assert.Nil(t, portal.portal(t).Client, "the unchecked error was not fixed") + assert.Len(t, portal.tenant.Clients(), clientsBefore, "no application was created") + + for _, link := range portal.portal(t).Navigation.Sidebar.Components { + assert.True(t, strings.HasSuffix(link.Type, ":v2")) + } + }) + + t.Run("the checklist offers every error, checked, with what the fix does", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.RefreshToken.RotationType = "rotating" }) + + var items []portals.ChecklistItem + + prompter := fixMenu(1) + prompter.ChecklistFunc = func(_ string, offered []portals.ChecklistItem) ([]int, error) { + items = offered + return nil, nil + } + + _, _, err := portal.auditInTerminal(t, prompter) + + require.NoError(t, err) + assert.Equal(t, []portals.ChecklistItem{ + {Label: "P003 Convert the sidebar links to the :v2 link types", Checked: true}, + {Label: "P013 Turn refresh token rotation off", Checked: true}, + }, items) + }) + + t.Run("skipping leaves the portal as it is", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + writes := len(portal.tenant.Writes()) + + outcome, _, err := portal.auditInTerminal(t, fixMenu(2)) + + require.NoError(t, err) + assert.True(t, outcome.Failed(), "the error is still reported") + assert.Len(t, portal.tenant.Writes(), writes) + }) + + t.Run("the menu is not offered when there is nothing to fix", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + + _, _, err := portal.auditInTerminal(t, &portalstest.Prompter{}) + + require.NoError(t, err, "a question would have failed the test") + }) + + t.Run("a cancel at the menu is passed on, and nothing was changed", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + writes := len(portal.tenant.Writes()) + cancelling := &portalstest.Prompter{ChooseFunc: func(string, []portals.Choice) (int, error) { return 0, portals.ErrCancelled }} + + _, _, err := portal.auditInTerminal(t, cancelling) + + require.True(t, errors.Is(err, portals.ErrCancelled)) + assert.Len(t, portal.tenant.Writes(), writes) + }) + + t.Run("the errors a fix exposes are asked about again", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.Client = nil }) + portal.tenant.DeleteClient(portal.clientID) + + menus := 0 + prompter := fixMenu(0) + prompter.ChooseFunc = func(string, []portals.Choice) (int, error) { + menus++ + return 0, nil + } + + _, reporter, err := portal.auditInTerminal(t, prompter) + + require.NoError(t, err) + assert.Equal(t, 2, menus, "once for the application, and once for its grant") + assert.True(t, reporter.Contains("detail", "These errors appeared after the fixes above.")) + }) +} diff --git a/internal/portals/audit_remedies.go b/internal/portals/audit_remedies.go new file mode 100644 index 000000000..551ab4763 --- /dev/null +++ b/internal/portals/audit_remedies.go @@ -0,0 +1,160 @@ +package portals + +import ( + "context" + "fmt" + "strings" +) + +// The fixes the audit can apply. Each one changes the portal or its application in the tenant. +// The detection of what is wrong is in audit_checks.go. + +const ( + internalLinkType = "sidebar:component:auth0:internal_link:v2" + externalLinkType = "sidebar:component:auth0:external_link:v2" +) + +// isLegacyLink reports whether a sidebar link has the legacy shape: it used "to" for pages and "url" +// for external links, or it has no :v2 type. +func isLegacyLink(item SidebarComponent) bool { + _, hasTo := item.Config["to"] + _, hasURL := item.Config["url"] + + return !strings.HasSuffix(item.Type, ":v2") || hasTo || hasURL +} + +// toV2Link converts a legacy sidebar link to the :v2 types. +func toV2Link(item SidebarComponent) SidebarComponent { + if !isLegacyLink(item) { + return item + } + + _, hasURL := item.Config["url"] + _, hasHref := item.Config["href"] + external := hasURL || hasHref || strings.Contains(item.Type, "external") + + config := map[string]any{"label": item.Config["label"], "icon": item.Config["icon"]} + + if external { + config["href"] = firstOf(item.Config["href"], item.Config["url"]) + + return SidebarComponent{Type: externalLinkType, Config: config} + } + + config["page_slug"] = firstOf(item.Config["page_slug"], item.Config["to"]) + + return SidebarComponent{Type: internalLinkType, Config: config} +} + +// firstOf returns the first value that is set. +func firstOf(values ...any) any { + for _, value := range values { + if value != nil { + return value + } + } + + return nil +} + +// createPortalApplication creates the application with the same defaults setup uses, or reuses the one +// an earlier run created, and links it to the portal. The template is business when the portal has +// organization components. The secret is only returned on create, so the link has to follow in the +// same step, and a rerun finds the application by name. +func createPortalApplication(ctx context.Context, api API, current snapshot, usesOrganization bool) error { + name := current.Portal.Name + if name == "" { + name = current.Portal.Slug + } + + template := TemplateConsumer + if usesOrganization { + template = TemplateBusiness + } + + client, err := ensurePortalClient(ctx, api, portalApplication{ + name: portalName(name), + slug: current.Portal.Slug, + template: template, + tenantDomain: current.TenantDomain, + host: current.host(), + isCustomDomain: current.CustomDomain != nil && current.CustomDomain.IsReady(), + }) + if err != nil { + return err + } + + link := &PortalClient{ + ClientID: client.value.clientID, + ClientSecret: client.value.clientSecret, + TokenEndpointAuthMethod: "client_secret_post", + } + + if err := api.UpdatePortal(ctx, current.Portal.ID, PortalPatch{Client: link}); err != nil { + return fmt.Errorf("link the application to portal %q: %w", current.Portal.Slug, err) + } + + return nil +} + +// patchRefresh changes the refresh token of the application. The refresh token has to be sent whole, +// with its rotation type, so the current settings are merged with the change. +func patchRefresh(ctx context.Context, api API, app Client, change func(token *RefreshToken)) error { + token := RefreshToken{ + RotationType: "non-rotating", + ExpirationType: "expiring", + TokenLifetime: intPointer(refreshTokenAbsoluteSeconds), + IdleTokenLifetime: intPointer(refreshTokenIdleSeconds), + } + + if current := app.RefreshToken; current != nil { + if current.RotationType != "" { + token.RotationType = current.RotationType + } + + if current.TokenLifetime != nil { + token.TokenLifetime = current.TokenLifetime + } + + if current.IdleTokenLifetime != nil { + token.IdleTokenLifetime = current.IdleTokenLifetime + } + + token.Policies = append([]RefreshTokenPolicy(nil), current.Policies...) + } + + change(&token) + + if err := api.UpdateClient(ctx, app.ID, ClientSettings{RefreshToken: &token}); err != nil { + return fmt.Errorf("update the refresh token of application %q: %w", app.ID, err) + } + + return nil +} + +// patchPages replaces the pages of the portal as a whole, so the page list is sent with the new home page. +func patchPages(ctx context.Context, api API, current snapshot, home string) error { + pages := &Pages{Default: home, Standard: current.Portal.pages()} + + if err := api.UpdatePortal(ctx, current.Portal.ID, PortalPatch{Pages: pages}); err != nil { + return fmt.Errorf("set the home page of portal %q: %w", current.Portal.Slug, err) + } + + return nil +} + +// addSidebarLink adds a link to a page at the end of the sidebar. +func addSidebarLink(ctx context.Context, api API, current snapshot, pageSlug, label, icon string) error { + components := append([]SidebarComponent(nil), current.Portal.sidebar()...) + components = append(components, SidebarComponent{ + Type: internalLinkType, + Config: map[string]any{"label": label, "page_slug": pageSlug, "icon": icon}, + }) + + patch := PortalPatch{Navigation: &Navigation{Sidebar: &Sidebar{Components: components}}} + if err := api.UpdatePortal(ctx, current.Portal.ID, patch); err != nil { + return fmt.Errorf("add %q to the sidebar of portal %q: %w", label, current.Portal.Slug, err) + } + + return nil +} diff --git a/internal/portals/audit_snapshot.go b/internal/portals/audit_snapshot.go new file mode 100644 index 000000000..f6ad1d63e --- /dev/null +++ b/internal/portals/audit_snapshot.go @@ -0,0 +1,290 @@ +package portals + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" +) + +// snapshot is everything the audit needs to know about one portal, read from the tenant. The rules work +// on the snapshot only, so they can be tested without a tenant. +type snapshot struct { + TenantDomain string + Portal Portal + // Application is nil when the portal has none, or when it was deleted. + Application *Client + Grants []ClientGrant + // ResourceServers are the identifiers of the resource servers of the tenant. + ResourceServers []string + // Forms are the forms the portal uses, and whether each exists in the tenant. + Forms []formStatus + // ApplicationConnections are the connections enabled for the application. + ApplicationConnections []Connection + CustomDomain *CustomDomain + Organization *snapshotOrganization +} + +// formStatus is a form a portal uses. +type formStatus struct { + ID string + Exists bool +} + +// snapshotOrganization is the organization the portal opens in. +type snapshotOrganization struct { + ID string + Name string + // Connections are the IDs of the connections enabled for the organization. + Connections []string +} + +// host returns where the portal is served from: the custom domain when it is ready. +func (s snapshot) host() string { + if s.CustomDomain != nil && s.CustomDomain.IsReady() { + return s.CustomDomain.Domain + } + + return s.TenantDomain +} + +func (p Portal) pages() []Page { + if p.Pages == nil { + return nil + } + + return p.Pages.Standard +} + +func (p Portal) homePage() string { + if p.Pages == nil { + return "" + } + + return p.Pages.Default +} + +func (p Portal) sidebar() []SidebarComponent { + if p.Navigation == nil || p.Navigation.Sidebar == nil { + return nil + } + + return p.Navigation.Sidebar.Components +} + +// children returns the components held by a section. +func (c Component) children() []Component { + items, _ := c.Config["children"].([]any) + children := make([]Component, 0, len(items)) + + for _, item := range items { + entry, isObject := item.(map[string]any) + if !isObject { + continue + } + + child := Component{} + child.Type, _ = entry["type"].(string) + child.Config, _ = entry["config"].(map[string]any) + children = append(children, child) + } + + return children +} + +// allComponents returns the components of the pages and the ones they hold, in the order they come. +func allComponents(pages []Page) []Component { + var all []Component + + var visit func(components []Component) + + visit = func(components []Component) { + for _, component := range components { + all = append(all, component) + visit(component.children()) + } + } + + for _, page := range pages { + visit(page.Components) + } + + return all +} + +// formIDsOf returns the IDs of the forms the pages use, each once. +func formIDsOf(portal Portal) []string { + var ids []string + + for _, component := range allComponents(portal.pages()) { + if component.Type != formComponentType { + continue + } + + if id, _ := component.Config["form_id"].(string); id != "" && !slices.Contains(ids, id) { + ids = append(ids, id) + } + } + + return ids +} + +const formComponentType = "page:component:auth0:form" + +// readSnapshot reads the portal, and then what the audit needs to know about it. The reads do not +// depend on each other, so they are sent together. +func readSnapshot(ctx context.Context, api API, portalID, tenantDomain, organizationID string) (snapshot, error) { + portal, err := api.ReadPortal(ctx, portalID) + if err != nil { + return snapshot{}, fmt.Errorf("read portal %q: %w", portalID, err) + } + + result := snapshot{TenantDomain: tenantDomain, Portal: portal} + + reads := []func() error{ + func() error { return result.readApplication(ctx, api) }, + func() error { return result.readResourceServers(ctx, api) }, + func() error { return result.readForms(ctx, api) }, + func() error { return result.readCustomDomain(ctx, api) }, + } + + if organizationID != "" { + reads = append(reads, func() error { return result.readOrganization(ctx, api, organizationID) }) + } + + // Each read fills a part of the result of its own. + failures := make([]error, len(reads)) + forEachParallel(len(reads), func(index int) { failures[index] = reads[index]() }) + + if err := errors.Join(failures...); err != nil { + return snapshot{}, err + } + + return result, nil +} + +// readApplication reads the application, and when it exists its grants and connections. A portal can +// still point at an application that was deleted. That is a finding (P009) and not a failure of the +// audit, so a 404 means no application. +func (s *snapshot) readApplication(ctx context.Context, api API) error { + if s.Portal.Client == nil || s.Portal.Client.ClientID == "" { + return nil + } + + clientID := s.Portal.Client.ClientID + + client, err := api.ReadClient(ctx, clientID) + if IsStatus(err, http.StatusNotFound) { + return nil + } + + if err != nil { + return fmt.Errorf("read application %q: %w", clientID, err) + } + + var ( + grants []ClientGrant + connections []Connection + ) + + failures := make([]error, 2) + forEachParallel(2, func(index int) { + if index == 0 { + grants, failures[0] = api.ListClientGrants(ctx, clientID) + } else { + connections, failures[1] = api.ListClientConnections(ctx, clientID) + } + }) + + if err := errors.Join(failures...); err != nil { + return fmt.Errorf("read the grants and connections of application %q: %w", clientID, err) + } + + s.Application = &client + s.Grants = grants + s.ApplicationConnections = connections + + return nil +} + +func (s *snapshot) readResourceServers(ctx context.Context, api API) error { + servers, err := api.ListResourceServers(ctx) + if err != nil { + return fmt.Errorf("list the resource servers: %w", err) + } + + for _, server := range servers { + s.ResourceServers = append(s.ResourceServers, server.Identifier) + } + + return nil +} + +// readForms checks that each form of the portal exists. The API answers 400 for an ID it cannot match +// to a form, because the ID fails its schema, which is the same finding as a deleted form (P007), so +// both mean the form is missing. +func (s *snapshot) readForms(ctx context.Context, api API) error { + ids := formIDsOf(s.Portal) + statuses := make([]formStatus, len(ids)) + failures := make([]error, len(ids)) + + forEachParallel(len(ids), func(index int) { + _, err := api.ReadForm(ctx, ids[index]) + statuses[index] = formStatus{ID: ids[index], Exists: err == nil} + + if err != nil && !IsStatus(err, http.StatusNotFound, http.StatusBadRequest) { + failures[index] = fmt.Errorf("read form %q: %w", ids[index], err) + } + }) + + if err := errors.Join(failures...); err != nil { + return err + } + + s.Forms = statuses + + return nil +} + +func (s *snapshot) readCustomDomain(ctx context.Context, api API) error { + domain, err := api.ReadDefaultCustomDomain(ctx) + if IsStatus(err, http.StatusNotFound) { + return nil + } + + if err != nil { + return fmt.Errorf("read the custom domain: %w", err) + } + + s.CustomDomain = &domain + + return nil +} + +func (s *snapshot) readOrganization(ctx context.Context, api API, organizationID string) error { + organization, err := api.ReadOrganization(ctx, organizationID) + if err != nil { + return fmt.Errorf("read organization %q: %w", organizationID, err) + } + + connections, err := api.ListOrganizationConnections(ctx, organizationID) + if err != nil { + return fmt.Errorf("list the connections of organization %q: %w", organizationID, err) + } + + s.Organization = &snapshotOrganization{ID: organization.ID, Name: organization.Name, Connections: connections} + + return nil +} + +// componentLabel is the name of a component in what the audit shows. +func componentLabel(component Component) string { + name := component.Type + if index := strings.LastIndex(name, ":"); index >= 0 { + name = name[index+1:] + } + + return strings.ReplaceAll(name, "_", " ") +} diff --git a/internal/portals/audit_test.go b/internal/portals/audit_test.go new file mode 100644 index 000000000..5ec86dd7b --- /dev/null +++ b/internal/portals/audit_test.go @@ -0,0 +1,300 @@ +package portals_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +// auditedPortal is a consumer portal made by setup, with a connection enabled for its application, so +// each test breaks exactly one thing. +type auditedPortal struct { + tenant *portalstest.Tenant + portalID string + clientID string +} + +func newAuditedPortal(t *testing.T) auditedPortal { + t.Helper() + + tenant := portalstest.New() + seedDatabase(tenant) + + result := runSetup(t, tenant, consumerOptions()) + tenant.EnableClientConnection(result.Application.ClientID, "con_db") + + return auditedPortal{tenant: tenant, portalID: result.Portal.ID, clientID: result.Application.ClientID} +} + +func (p auditedPortal) audit(t *testing.T, options portals.AuditOptions) (portals.AuditOutcome, *portalstest.Reporter) { + t.Helper() + + options.PortalID = p.portalID + + reporter := &portalstest.Reporter{} + outcome, err := portals.Audit(context.Background(), options, portals.AuditDeps{ + API: p.tenant, + Reporter: reporter, + Prompter: &portalstest.Prompter{}, + TenantDomain: tenantDomain, + }) + require.NoError(t, err) + + return outcome, reporter +} + +func (p auditedPortal) findings(t *testing.T) []portals.Finding { + t.Helper() + + findings, err := portals.FindingsOf(context.Background(), p.tenant, tenantDomain, p.portalID) + require.NoError(t, err) + + return findings +} + +// legacyLink is a sidebar link in the shape the portals used before the :v2 types. +func legacyLink(to string) portals.SidebarComponent { + return portals.SidebarComponent{ + Type: "sidebar:component:auth0:internal_link", + Config: map[string]any{"label": "Profile", "to": to, "icon": "user"}, + } +} + +func (p auditedPortal) setSidebar(components ...portals.SidebarComponent) { + p.tenant.ModifyPortal(p.portalID, func(portal *portals.Portal) { portal.Navigation.Sidebar.Components = components }) +} + +// withLegacyProfileLink gives the sidebar of the portal a link in the legacy shape for the first page, +// one that still names the page, so it is the only thing wrong with the sidebar. +func (p auditedPortal) withLegacyProfileLink() { + legacy := legacyLink("profile") + legacy.Config["page_slug"] = "profile" + + p.setSidebar( + legacy, + sidebarLink("Security", "security", "shield-check"), + sidebarLink("Legal & privacy", "legal-privacy", "file-text"), + ) +} + +func TestAudit_APortalMadeBySetup(t *testing.T) { + t.Parallel() + + t.Run("a consumer portal passes", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + + outcome, _ := portal.audit(t, portals.AuditOptions{}) + + assert.Empty(t, outcome.Result.Findings) + assert.False(t, outcome.Failed()) + }) + + t.Run("a business portal passes", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedDatabase(tenant) + seedUser(tenant, "auth0|jane", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|jane") + seedAdminRole(tenant) + tenant.AssignRole("org_1", "auth0|jane", "rol_admin") + + result := runSetup(t, tenant, businessOptions("auth0|jane")) + tenant.EnableClientConnection(result.Application.ClientID, "con_db") + portal := auditedPortal{tenant: tenant, portalID: result.Portal.ID, clientID: result.Application.ClientID} + + outcome, _ := portal.audit(t, portals.AuditOptions{Organization: "org_1"}) + + assert.Empty(t, outcome.Result.Findings) + }) +} + +func TestAudit_WhatItReports(t *testing.T) { + t.Parallel() + + t.Run("a deleted form is an error that fails the audit", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.DeleteForm(portal.tenant.Forms()[0].ID) + + outcome, _ := portal.audit(t, portals.AuditOptions{}) + + assert.Contains(t, idsOf(outcome.Result.Findings), "P007") + assert.True(t, outcome.Failed()) + }) + + t.Run("a form ID the API cannot match is a missing form, not a crash", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { + p.Pages.Standard[0].Components[0].Config["children"].([]any)[0].(map[string]any)["config"].(map[string]any)["form_id"] = "ap_not-a-real-form-id" + }) + + outcome, _ := portal.audit(t, portals.AuditOptions{}) + + assert.Contains(t, idsOf(outcome.Result.Findings), "P007") + }) + + t.Run("a deleted application is P009, not a crash", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.DeleteClient(portal.clientID) + + outcome, _ := portal.audit(t, portals.AuditOptions{}) + + assert.Equal(t, []string{"P009"}, idsOf(outcome.Result.Findings)) + assert.True(t, outcome.Failed()) + }) + + t.Run("a warning fails the audit only in a strict audit", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyPortal(portal.portalID, func(p *portals.Portal) { p.CustomCode = &portals.CustomCode{Head: ""} }) + + lenient, _ := portal.audit(t, portals.AuditOptions{}) + strict, _ := portal.audit(t, portals.AuditOptions{Strict: true}) + + assert.Equal(t, 1, lenient.Result.Warnings) + assert.False(t, lenient.Failed()) + assert.True(t, strict.Failed()) + }) + + t.Run("an organization with no connection in common with the application is P022", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedDatabase(tenant) + seedUser(tenant, "auth0|jane", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|jane") + seedAdminRole(tenant) + tenant.AssignRole("org_1", "auth0|jane", "rol_admin") + result := runSetup(t, tenant, businessOptions("auth0|jane")) + tenant.EnableClientConnection(result.Application.ClientID, "con_other") + tenant.AddConnection(portals.Connection{ID: "con_other", Name: "Other"}, "google-oauth2") + portal := auditedPortal{tenant: tenant, portalID: result.Portal.ID, clientID: result.Application.ClientID} + + outcome, _ := portal.audit(t, portals.AuditOptions{Organization: "org_1"}) + + assert.Equal(t, []string{"P022"}, idsOf(outcome.Result.Findings)) + }) + + t.Run("a portal that does not exist is an error", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.portalID = "up_missing" + + _, err := portals.Audit(context.Background(), portals.AuditOptions{PortalID: "up_missing"}, portals.AuditDeps{ + API: portal.tenant, Reporter: &portalstest.Reporter{}, Prompter: &portalstest.Prompter{}, TenantDomain: tenantDomain, + }) + + assert.True(t, portals.IsStatus(err, http.StatusNotFound), "got %v", err) + }) + + t.Run("a refused read says which scope it needs", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.Deny("GET portals/"+portal.portalID, "Insufficient scope, expected any of: read:portals") + + _, err := portals.Audit(context.Background(), portals.AuditOptions{PortalID: portal.portalID}, portals.AuditDeps{ + API: portal.tenant, Reporter: &portalstest.Reporter{}, Prompter: &portalstest.Prompter{}, TenantDomain: tenantDomain, + }) + + require.Error(t, err) + assert.Equal(t, portals.ScopeFixes([]string{"read:portals"}), portals.FixesOf(err)) + }) + + t.Run("reads that fail together are all reported", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.FailNext("GET resource-servers", http.StatusInternalServerError) + portal.tenant.FailNext("GET custom-domains/default", http.StatusInternalServerError) + + _, err := portals.Audit(context.Background(), portals.AuditOptions{PortalID: portal.portalID}, portals.AuditDeps{ + API: portal.tenant, Reporter: &portalstest.Reporter{}, Prompter: &portalstest.Prompter{}, TenantDomain: tenantDomain, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "resource servers") + assert.Contains(t, err.Error(), "custom domain") + }) +} + +func TestAudit_WhatItShows(t *testing.T) { + t.Parallel() + + t.Run("a healthy portal", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + + _, reporter := portal.audit(t, portals.AuditOptions{}) + + assert.Equal(t, []string{ + "banner: Universal Portals audit | acme.us.auth0.com", + "task: Reading the portal", + `section: Portal "acme-account"`, + "item: No problems found | ", + "newline: ", + "item: 0 errors, 0 warnings | ", + }, reporter.Lines()) + }) + + t.Run("a table of what it found, the commands for what it cannot fix, and a summary", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.IPHeaderTrusted = boolPointer(false) }) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { + client.SessionTransfer = &portals.SessionTransfer{} + }) + portal.tenant.DeleteGrant(portal.tenant.Grants()[0].ID) + + _, reporter := portal.audit(t, portals.AuditOptions{}) + + lines := reporter.Lines() + assert.Contains(t, lines, "table: ,ID,Check,Fix") + assert.Contains(t, lines, "row: ✖,P003,navigation shape,Convert the sidebar links to the :v2 link types") + assert.Contains(t, lines, "row: ▲,P017,token endpoint IP header,Trust the token endpoint IP header") + assert.Contains(t, lines, "row: ▲,P024,session transfer,Allow session transfer on the portal application") + assert.True(t, reporter.Contains("detail", "P024 manually: auth0 api patch")) + assert.True(t, reporter.Contains("error", "2 errors, 2 warnings"), "%v", lines) + }) + + t.Run("one error and no warning", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.withLegacyProfileLink() + + _, reporter := portal.audit(t, portals.AuditOptions{}) + + assert.True(t, reporter.Contains("error", "1 error, 0 warnings")) + }) + + t.Run("only warnings", func(t *testing.T) { + t.Parallel() + + portal := newAuditedPortal(t) + portal.tenant.ModifyClient(portal.clientID, func(client *portals.Client) { client.IPHeaderTrusted = boolPointer(false) }) + + _, reporter := portal.audit(t, portals.AuditOptions{}) + + assert.True(t, reporter.Contains("warn", "0 errors, 1 warning")) + }) +} diff --git a/internal/portals/business.go b/internal/portals/business.go new file mode 100644 index 000000000..531683c6f --- /dev/null +++ b/internal/portals/business.go @@ -0,0 +1,761 @@ +package portals + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "slices" + "strings" +) + +// A business portal is opened for an organization, so the user who will test it has to belong to that +// organization, with a connection to sign in with and a role. Setup asks for the user before it +// creates the portal, and checks each step before it changes anything. + +const ( + businessRequirements = "Business portals only work in the context of an organization: without it, the organization " + + "components do not load. Pick the user who will test the portal, so the link opens in their organization." + skippedTestUserRequirements = "Without a test user, the link needs an organization. The portal only signs members in " + + "when the URL carries ?organization=." + + // The role setup creates when no role of the tenant has the permissions. It carries only the + // organization permissions of the portal components, nothing more. + portalRoleName = "Universal Portals - Admin" + + maxOrganizationNameLength = 50 +) + +// Organization names are identifiers: lowercase letters, numbers, hyphens and underscores, and +// unique in the tenant. The display name is free text. +var ( + organizationNamePattern = regexp.MustCompile(`^[a-z0-9_-]{1,50}$`) + notOrganizationName = regexp.MustCompile(`[^a-z0-9_-]+`) +) + +// businessAccess is the access of the test user to an organization. +type businessAccess struct { + userID string + organizationID string + organizationName string + // Whether the roles of the user together grant every organization scope. + hasOrganizationRole bool +} + +// testLink is the organization the link of the portal opens in, and the access of the test user when +// one was chosen. +type testLink struct { + organizationID string + // True while the organization is still the placeholder to replace. + isPlaceholder bool + access *businessAccess +} + +func userLabel(user User) string { + if user.Email != "" { + return user.Email + } + + return user.ID +} + +func describeUser(user User) string { + email := user.Email + if email == "" { + email = "no email" + } + + description := email + " · " + user.ID + if user.Name != "" { + description += " · " + user.Name + } + + return description +} + +// addMemberCommand is the command that adds a user to an organization. +func addMemberCommand(organization, userID string) string { + return fmt.Sprintf(`Add them: auth0 api post "organizations/%s/members" --data '{"members":["%s"]}'`, organization, userID) +} + +// chooseTestLink settles the test user before anything is created, so a user who cannot sign in stops +// the run with nothing to clean up. Skipping the user asks for the organization ID of the link +// instead, with a placeholder when none is given. +func (r *setupRun) chooseTestLink(ctx context.Context) (testLink, error) { + r.report().Section("Test user") + + userQuery := r.options.UserQuery + + if userQuery == "" && r.deps.Interactive { + r.report().Info(businessRequirements) + + answer, err := r.deps.Prompter.Ask("Enter the test user's email or user ID (press Enter to skip)", "", "jane@acme.com") + if err != nil { + return testLink{}, err + } + + userQuery = strings.TrimSpace(answer) + } + + if userQuery != "" { + access, err := r.resolveBusinessAccess(ctx, userQuery) + if err != nil { + return testLink{}, err + } + + return testLink{organizationID: access.organizationID, access: &access}, nil + } + + if r.deps.Interactive { + r.report().Info(skippedTestUserRequirements) + } + + organizationID, err := r.deps.Prompter.Ask( + "Enter the organization ID to test in (the link adds ?organization=)", + organizationPlaceholder, + "", + ) + if err != nil { + return testLink{}, err + } + + return testLink{organizationID: organizationID, isPlaceholder: organizationID == organizationPlaceholder}, nil +} + +func (r *setupRun) resolveBusinessAccess(ctx context.Context, userQuery string) (businessAccess, error) { + user, err := r.chooseUser(ctx, userQuery) + if err != nil { + return businessAccess{}, err + } + + r.report().Item(fmt.Sprintf("%q found", userLabel(user)), "") + + organization, err := r.chooseOrganization(ctx, user) + if err != nil { + return businessAccess{}, err + } + + r.report().Item(fmt.Sprintf("Organization %q selected", organization.Name), "") + + if err := r.requireEnabledConnection(ctx, organization); err != nil { + return businessAccess{}, err + } + + hasRole, err := r.ensureOrganizationRole(ctx, user, organization) + if err != nil { + return businessAccess{}, err + } + + return businessAccess{ + userID: user.ID, + organizationID: organization.ID, + organizationName: organization.Name, + hasOrganizationRole: hasRole, + }, nil +} + +// lookupUsers looks a user up by email, or by ID when the query has no "@". An unknown ID is an empty +// result and not an error, so the caller can ask again. +func (r *setupRun) lookupUsers(ctx context.Context, query string) ([]User, error) { + if strings.Contains(query, "@") { + found, err := r.deps.API.SearchUsersByEmail(ctx, query) + if err != nil { + return nil, fmt.Errorf("search the users by email: %w", err) + } + + // The search matches similar addresses too, so only the exact email is kept. + return slices.DeleteFunc(found, func(user User) bool { return !strings.EqualFold(user.Email, query) }), nil + } + + user, err := r.deps.API.ReadUser(ctx, query) + if IsStatus(err, http.StatusNotFound) { + return nil, nil + } + + if err != nil { + return nil, fmt.Errorf("read user %q: %w", query, err) + } + + return []User{user}, nil +} + +func (r *setupRun) chooseUser(ctx context.Context, initialQuery string) (User, error) { + query := initialQuery + + for { + if query == "" { + if !r.deps.Interactive { + return User{}, &BusinessAccessError{ + Message: "A Business portal needs the user who will test it", + Hints: []string{"Rerun with --user ."}, + } + } + + answer, err := r.deps.Prompter.Ask("Enter the test user's email or user ID", "", "jane@acme.com") + if err != nil { + return User{}, err + } + + if query = strings.TrimSpace(answer); query == "" { + continue + } + } + + matches, err := r.lookupUsers(ctx, query) + if err != nil { + return User{}, err + } + + if len(matches) == 1 { + return matches[0], nil + } + + if !r.deps.Interactive { + return User{}, noSingleUser(query, matches) + } + + if len(matches) == 0 { + r.report().Warn(fmt.Sprintf("No user matches %q.", query)) + + query = "" + + continue + } + + chosen, retry, err := r.pickUser(query, matches) + if err != nil { + return User{}, err + } + + if retry { + query = "" + continue + } + + return chosen, nil + } +} + +// noSingleUser is the error of a run that cannot ask which user is meant. +func noSingleUser(query string, matches []User) error { + if len(matches) == 0 { + return &BusinessAccessError{ + Message: fmt.Sprintf("No user matches %q", query), + Hints: []string{"Check the email or user ID, then rerun with --user."}, + } + } + + ids := make([]string, 0, len(matches)) + for _, user := range matches { + ids = append(ids, user.ID) + } + + return &BusinessAccessError{ + Message: fmt.Sprintf("More than one user has the email %q", query), + Hints: []string{"Rerun with the user ID instead: " + strings.Join(ids, ", ")}, + } +} + +// pickUser lets the person choose among the users that match, or look for another. +func (r *setupRun) pickUser(query string, matches []User) (user User, retry bool, err error) { + options := make([]Choice, 0, len(matches)+1) + for _, match := range matches { + options = append(options, Choice{Label: describeUser(match)}) + } + + options = append(options, Choice{Label: "Try another email or user ID"}) + + picked, err := r.deps.Prompter.Choose(fmt.Sprintf("%d users match %q", len(matches), query), options) + if err != nil { + return User{}, false, err + } + + if picked == len(matches) { + return User{}, true, nil + } + + return matches[picked], false, nil +} + +func (r *setupRun) chooseOrganization(ctx context.Context, user User) (Organization, error) { + memberships, err := r.deps.API.ListUserOrganizations(ctx, user.ID) + if err != nil { + return Organization{}, fmt.Errorf("list the organizations of user %q: %w", user.ID, err) + } + + if requested := r.options.Organization; requested != "" { + for _, membership := range memberships { + if membership.ID == requested || membership.Name == requested { + return membership, nil + } + } + + return Organization{}, &BusinessAccessError{ + Message: fmt.Sprintf("%s is not a member of %q", userLabel(user), requested), + Hints: []string{addMemberCommand(requested, user.ID)}, + } + } + + if !r.deps.Interactive { + return nonInteractiveOrganization(user, memberships) + } + + if len(memberships) == 1 { + return memberships[0], nil + } + + // Members pick one of their organizations. A user with none can join an existing organization of the + // tenant. Either way a new one can be created. + candidates := memberships + + if len(memberships) == 0 { + candidates, err = r.deps.API.ListOrganizations(ctx) + if err != nil { + return Organization{}, fmt.Errorf("list the organizations: %w", err) + } + + r.report().Warn(fmt.Sprintf("%s is not a member of any organization.", userLabel(user))) + } + + r.report().Info("The portal opens in one organization, and the test user has to be a member of it.") + + options := make([]Choice, 0, len(candidates)+1) + for _, candidate := range candidates { + options = append(options, Choice{Label: fmt.Sprintf("%s (%s)", candidate.Name, candidate.ID)}) + } + + options = append(options, Choice{Label: "None of these, create a new organization"}) + + picked, err := r.deps.Prompter.Choose("Choose the organization to test the portal in", options) + if err != nil { + return Organization{}, err + } + + if picked == len(candidates) { + return r.createOrganization(ctx, user) + } + + if len(memberships) > 0 { + return candidates[picked], nil + } + + return r.addToOrganization(ctx, user, candidates[picked]) +} + +// nonInteractiveOrganization picks the organization when nobody can be asked. +func nonInteractiveOrganization(user User, memberships []Organization) (Organization, error) { + switch len(memberships) { + case 1: + return memberships[0], nil + case 0: + return Organization{}, &BusinessAccessError{ + Message: fmt.Sprintf("%s is not a member of any organization", userLabel(user)), + Hints: []string{addMemberCommand("", user.ID)}, + } + default: + ids := make([]string, 0, len(memberships)) + for _, membership := range memberships { + ids = append(ids, membership.ID) + } + + return Organization{}, &BusinessAccessError{ + Message: fmt.Sprintf("%s belongs to %d organizations", userLabel(user), len(memberships)), + Hints: []string{"Rerun with --organization : " + strings.Join(ids, ", ")}, + } + } +} + +func (r *setupRun) addToOrganization(ctx context.Context, user User, organization Organization) (Organization, error) { + confirmed, err := r.deps.Prompter.Confirm(fmt.Sprintf("Add %s as a member of %s?", userLabel(user), organization.Name), true) + if err != nil { + return Organization{}, err + } + + if !confirmed { + return Organization{}, &BusinessAccessError{ + Message: fmt.Sprintf("%s is not a member of an organization, and setup did not add them", userLabel(user)), + Hints: []string{addMemberCommand(organization.ID, user.ID)}, + } + } + + if err := r.deps.API.AddOrganizationMember(ctx, organization.ID, user.ID); err != nil { + return Organization{}, fmt.Errorf("add %s to organization %q: %w", userLabel(user), organization.Name, err) + } + + r.report().Item(fmt.Sprintf("Added %s to %s", userLabel(user), organization.Name), "") + + return organization, nil +} + +// suggestOrganizationName makes a name from the display name of an organization. +func suggestOrganizationName(displayName string) string { + name := strings.Trim(notOrganizationName.ReplaceAllString(strings.ToLower(displayName), "-"), "-") + + if len(name) > maxOrganizationNameLength { + name = name[:maxOrganizationNameLength] + } + + return name +} + +// createOrganization creates an organization, enables a database connection on it, and adds the +// user. Each write is confirmed first. A taken name asks again, as the slug of the portal does, with a +// suggestion that is free to use. +func (r *setupRun) createOrganization(ctx context.Context, user User) (Organization, error) { + answer, err := r.deps.Prompter.Ask("Organization display name", "", "Acme Corp") + if err != nil { + return Organization{}, err + } + + displayName := strings.TrimSpace(answer) + if displayName == "" { + return Organization{}, &BusinessAccessError{Message: "The organization needs a display name"} + } + + suggestion := suggestOrganizationName(displayName) + + for { + name, err := r.askOrganizationName(suggestion) + if err != nil { + return Organization{}, err + } + + confirmed, err := r.deps.Prompter.Confirm( + fmt.Sprintf("Create organization %q (%s), enable a database connection on it, and add %s?", displayName, name, userLabel(user)), + true, + ) + if err != nil { + return Organization{}, err + } + + if !confirmed { + return Organization{}, &BusinessAccessError{ + Message: "No organization was created", + Hints: []string{fmt.Sprintf("Create one: auth0 orgs create --name %s --display %q", name, displayName)}, + } + } + + created, err := r.deps.API.CreateOrganization(ctx, OrganizationSpec{Name: name, DisplayName: displayName}) + if IsStatus(err, http.StatusConflict) { + // Only a taken name asks again. The later failures happen after the organization exists, so + // asking for a new name would create a second one. + r.report().Warn(fmt.Sprintf("The organization name %q is already used in this tenant.", name)) + + suggestion = name + "-2" + if len(suggestion) > maxOrganizationNameLength { + suggestion = suggestion[:maxOrganizationNameLength] + } + + continue + } + + if err != nil { + return Organization{}, fmt.Errorf("create organization %q: %w", name, err) + } + + r.report().Item(fmt.Sprintf("Created organization %q", displayName), "") + + return created, r.connectOrganization(ctx, created, user, displayName) + } +} + +// connectOrganization enables a database connection on a new organization and adds the user to it. +func (r *setupRun) connectOrganization(ctx context.Context, organization Organization, user User, displayName string) error { + connectionID, err := r.chooseDatabaseConnection(ctx) + if err != nil { + return err + } + + if err := r.deps.API.EnableOrganizationConnection(ctx, organization.ID, connectionID); err != nil { + return fmt.Errorf("enable the connection for organization %q: %w", organization.Name, err) + } + + if err := r.deps.API.AddOrganizationMember(ctx, organization.ID, user.ID); err != nil { + return fmt.Errorf("add %s to organization %q: %w", userLabel(user), organization.Name, err) + } + + r.report().Item(fmt.Sprintf("Added %s to %s", userLabel(user), displayName), "") + + return nil +} + +func (r *setupRun) askOrganizationName(suggestion string) (string, error) { + for { + answer, err := r.deps.Prompter.Ask("Organization name", suggestion, "") + if err != nil { + return "", err + } + + if name := strings.TrimSpace(answer); organizationNamePattern.MatchString(name) { + return name, nil + } + + r.report().Error("Use lowercase letters, numbers, hyphens and underscores, up to 50 characters.") + } +} + +func (r *setupRun) chooseDatabaseConnection(ctx context.Context) (string, error) { + connections, err := r.deps.API.ListDatabaseConnections(ctx) + if err != nil { + return "", fmt.Errorf("list the database connections: %w", err) + } + + switch len(connections) { + case 0: + return "", &BusinessAccessError{ + Message: "This tenant has no database connection to sign in with", + Hints: []string{"Create one in the dashboard, then rerun setup."}, + } + case 1: + return connections[0].ID, nil + } + + options := make([]Choice, 0, len(connections)) + for _, connection := range connections { + options = append(options, Choice{Label: connection.Name}) + } + + picked, err := r.deps.Prompter.Choose("Database connection for the organization", options) + if err != nil { + return "", err + } + + return connections[picked].ID, nil +} + +func (r *setupRun) requireEnabledConnection(ctx context.Context, organization Organization) error { + connections, err := r.deps.API.ListOrganizationConnections(ctx, organization.ID) + if err != nil { + return fmt.Errorf("list the connections of organization %q: %w", organization.Name, err) + } + + if len(connections) > 0 { + return nil + } + + return &BusinessAccessError{ + Message: fmt.Sprintf("Organization %q has no enabled connections, so members cannot sign in", organization.Name), + Hints: []string{fmt.Sprintf( + `Enable one: auth0 api post "organizations/%s/enabled_connections" --data '{"connection_id":"","assign_membership_on_login":false}'`, + organization.ID, + )}, + } +} + +// ensureOrganizationRole returns whether the organization roles of the user, together, grant every +// scope the organization components need. Missing scopes are a warning and not a stop: the portal +// still signs the user in, but those components do not work for them. +func (r *setupRun) ensureOrganizationRole(ctx context.Context, user User, organization Organization) (bool, error) { + roles, err := r.deps.API.ListMemberRoles(ctx, organization.ID, user.ID) + if err != nil { + return false, fmt.Errorf("list the roles of %s in organization %q: %w", userLabel(user), organization.Name, err) + } + + granted := map[string]bool{} + + for _, role := range roles { + scopes, err := r.permissionsOf(ctx, role.ID) + if err != nil { + return false, err + } + + for _, scope := range scopes { + granted[scope] = true + } + } + + var missing []string + + for _, scope := range myOrganizationScopes { + if !granted[scope] { + missing = append(missing, scope) + } + } + + if len(missing) == 0 { + return true, nil + } + + r.warnAboutMissingPermissions(user, organization, missing) + + if !r.deps.Interactive { + return false, nil + } + + return r.offerRole(ctx, user, organization, missing) +} + +func (r *setupRun) warnAboutMissingPermissions(user User, organization Organization, missing []string) { + var reads, writes []string + + for _, scope := range missing { + if strings.HasPrefix(scope, "read:") { + reads = append(reads, scope) + } else { + writes = append(writes, scope) + } + } + + if len(reads) > 0 { + r.report().Warn(fmt.Sprintf( + "%s lacks read permissions for the organization components in %q: %s. Those components will not open for them.", + userLabel(user), organization.Name, strings.Join(reads, ", "), + )) + } + + if len(writes) > 0 { + r.report().Warn(fmt.Sprintf( + "%s lacks write permissions for the organization components in %q: %s. The components open, but changes cannot be saved.", + userLabel(user), organization.Name, strings.Join(writes, ", "), + )) + } +} + +// offerRole offers to give the user a role that grants the missing permissions. +func (r *setupRun) offerRole(ctx context.Context, user User, organization Organization, missing []string) (bool, error) { + candidates, err := r.rolesGranting(ctx, missing) + if err != nil { + return false, err + } + + var role Role + + if len(candidates) == 0 { + create, err := r.deps.Prompter.Confirm( + fmt.Sprintf("No role in this tenant has these permissions. Create %q and assign it to %s?", portalRoleName, userLabel(user)), + true, + ) + if err != nil || !create { + return false, err + } + + if role, err = r.createPortalRole(ctx); err != nil { + return false, err + } + } else { + assign, err := r.deps.Prompter.Confirm("Assign an organization role now?", true) + if err != nil || !assign { + return false, err + } + + options := make([]Choice, 0, len(candidates)+1) + for _, candidate := range candidates { + options = append(options, Choice{Label: candidate.Name}) + } + + options = append(options, Choice{Label: fmt.Sprintf("Create %q", portalRoleName), Hint: "only the permissions the portal needs"}) + + picked, err := r.deps.Prompter.Choose("Role to assign", options) + if err != nil { + return false, err + } + + if picked == len(candidates) { + role, err = r.createPortalRole(ctx) + } else { + role = candidates[picked] + } + + if err != nil { + return false, err + } + } + + if err := r.deps.API.AssignMemberRole(ctx, organization.ID, user.ID, role.ID); err != nil { + return false, fmt.Errorf("assign role %q to %s: %w", role.Name, userLabel(user), err) + } + + r.report().Item(fmt.Sprintf("Assigned role %s to %s", role.Name, userLabel(user)), "") + + return true, nil +} + +// createPortalRole creates the role of the portal components. A role with the same name is reused, and +// only the missing permissions are added to it. +func (r *setupRun) createPortalRole(ctx context.Context) (Role, error) { + tenantRoles, err := r.deps.API.ListRoles(ctx) + if err != nil { + return Role{}, fmt.Errorf("list the roles: %w", err) + } + + var role Role + + if index := slices.IndexFunc(tenantRoles, func(candidate Role) bool { return candidate.Name == portalRoleName }); index >= 0 { + role = tenantRoles[index] + } else { + role, err = r.deps.API.CreateRole(ctx, RoleSpec{ + Name: portalRoleName, + Description: "The organization permissions the Universal Portals components need.", + }) + if err != nil { + return Role{}, fmt.Errorf("create role %q: %w", portalRoleName, err) + } + } + + granted, err := r.permissionsOf(ctx, role.ID) + if err != nil { + return Role{}, err + } + + var missing []Permission + + for _, scope := range myOrganizationScopes { + if !slices.Contains(granted, scope) { + missing = append(missing, Permission{Name: scope, ResourceServerIdentifier: myOrganizationAudience(r.tenant)}) + } + } + + if len(missing) > 0 { + if err := r.deps.API.AddRolePermissions(ctx, role.ID, missing); err != nil { + return Role{}, fmt.Errorf("add the permissions to role %q: %w", role.Name, err) + } + } + + return role, nil +} + +func (r *setupRun) permissionsOf(ctx context.Context, roleID string) ([]string, error) { + permissions, err := r.deps.API.ListRolePermissions(ctx, roleID) + if err != nil { + return nil, fmt.Errorf("list the permissions of role %q: %w", roleID, err) + } + + names := make([]string, 0, len(permissions)) + for _, permission := range permissions { + names = append(names, permission.Name) + } + + return names, nil +} + +// rolesGranting returns the roles of the tenant that grant every one of the scopes, so assigning one +// fixes the warning. +func (r *setupRun) rolesGranting(ctx context.Context, scopes []string) ([]Role, error) { + tenantRoles, err := r.deps.API.ListRoles(ctx) + if err != nil { + return nil, fmt.Errorf("list the roles: %w", err) + } + + // Each read fills the slot of its own role, and the roles keep the order the tenant lists them in. + granted := make([][]string, len(tenantRoles)) + failures := make([]error, len(tenantRoles)) + + forEachParallel(len(tenantRoles), func(index int) { + granted[index], failures[index] = r.permissionsOf(ctx, tenantRoles[index].ID) + }) + + if err := errors.Join(failures...); err != nil { + return nil, err + } + + var matches []Role + + for index, role := range tenantRoles { + if !slices.ContainsFunc(scopes, func(scope string) bool { return !slices.Contains(granted[index], scope) }) { + matches = append(matches, role) + } + } + + return matches, nil +} diff --git a/internal/portals/business_test.go b/internal/portals/business_test.go new file mode 100644 index 000000000..31f8c42f2 --- /dev/null +++ b/internal/portals/business_test.go @@ -0,0 +1,895 @@ +package portals_test + +import ( + "context" + "errors" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +func businessOptions(userQuery string) portals.SetupOptions { + return portals.SetupOptions{ + Template: portals.TemplateBusiness, + Name: "Acme Admin", + Slug: "acme-admin", + UserQuery: userQuery, + } +} + +// answers are the replies of a scripted prompter, in the order the questions come. +type answers struct { + asks []string + chooses []string + confirms []bool +} + +// prompter answers from the script. A choice is the option whose label contains the text. +func (a answers) prompter() *portalstest.Prompter { + asks, chooses, confirms := a.asks, a.chooses, a.confirms + + return &portalstest.Prompter{ + AskFunc: func(question, _, _ string) (string, error) { + if len(asks) == 0 { + return "", errors.New("unexpected question: " + question) + } + + next := asks[0] + asks = asks[1:] + + return next, nil + }, + ConfirmFunc: func(question string, _ bool) (bool, error) { + if len(confirms) == 0 { + return false, errors.New("unexpected confirmation: " + question) + } + + next := confirms[0] + confirms = confirms[1:] + + return next, nil + }, + ChooseFunc: func(question string, options []portals.Choice) (int, error) { + if len(chooses) == 0 { + return 0, errors.New("unexpected choice: " + question) + } + + want := chooses[0] + chooses = chooses[1:] + + for index, option := range options { + if strings.Contains(option.Label, want) { + return index, nil + } + } + + return 0, errors.New("no option matching " + want) + }, + } +} + +func seedUser(tenant *portalstest.Tenant, id, email string) { + tenant.AddUser(portals.User{ID: id, Email: email, Name: strings.Split(email, "@")[0]}) +} + +func permissionsOf(scopes []string) []portals.Permission { + permissions := make([]portals.Permission, 0, len(scopes)) + for _, scope := range scopes { + permissions = append(permissions, portals.Permission{Name: scope}) + } + + return permissions +} + +// seedOrganization adds an organization with a connection, and the users that belong to it. +func seedOrganization(tenant *portalstest.Tenant, id, name string, members ...string) { + tenant.AddOrganization(portals.Organization{ID: id, Name: name}, members...) + tenant.EnableOrganizationConnectionFor(id, "con_db") +} + +func seedDatabase(tenant *portalstest.Tenant) { + tenant.AddConnection(portals.Connection{ID: "con_db", Name: "Username-Password-Authentication"}, "auth0") +} + +func seedAdminRole(tenant *portalstest.Tenant) { + tenant.AddRole(portals.Role{ID: "rol_admin", Name: "Organization - Admin"}, permissionsOf(portals.MyOrganizationScopes())...) +} + +// interactiveDeps are the dependencies of a run with somebody to ask. +func interactiveDeps(tenant *portalstest.Tenant, script answers) (portals.SetupDeps, *portalstest.Reporter, *portalstest.Prompter) { + deps, reporter, _ := newSetupDeps(tenant) + prompter := script.prompter() + deps.Prompter = prompter + deps.Interactive = true + + return deps, reporter, prompter +} + +func setupBusiness(tenant *portalstest.Tenant, options portals.SetupOptions, deps portals.SetupDeps) (portals.SetupResult, error) { + return portals.Setup(context.Background(), options, deps) +} + +func TestSetup_BusinessPortal(t *testing.T) { + t.Parallel() + + t.Run("it is created with the organization in its URL", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|user1", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme-corp", "auth0|user1") + seedAdminRole(tenant) + tenant.AssignRole("org_1", "auth0|user1", "rol_admin") + deps, _, _ := newSetupDeps(tenant) + + result, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + require.NoError(t, err) + assert.Equal(t, "https://acme.us.auth0.com/portals/acme-admin?organization=org_1", result.Portal.URL) + assert.Equal(t, &portals.OrganizationResult{ID: "org_1", Name: "acme-corp", HasRole: true}, result.Organization) + + client := clientNamed(t, tenant, "Acme Admin") + policies := map[string][]string{} + + for _, policy := range client.RefreshToken.Policies { + policies[policy.Audience] = policy.Scope + } + + assert.Equal(t, portals.AudiencesFor(portals.TemplateBusiness, tenantDomain), policies, "a policy for both APIs") + + grants := map[string][]string{} + for _, grant := range tenant.Grants() { + grants[grant.Audience] = grant.Scope + } + + assert.Equal(t, portals.MyOrganizationScopes(), grants["https://acme.us.auth0.com/my-org/"]) + assert.Equal(t, portals.MyAccountScopes(), grants["https://acme.us.auth0.com/me/"]) + assert.Len(t, tenant.ResourceServers(), 2, "the My Organization API and the My Account API") + }) + + t.Run("it needs the user up front when nobody can be asked", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, _, _ := newSetupDeps(tenant) + + _, err := setupBusiness(tenant, businessOptions(""), deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.Equal(t, []string{"Rerun with --user ."}, portals.FixesOf(err)) + assert.Empty(t, tenant.Clients()) + }) + + t.Run("an unknown test user stops before the application, grants or vault are created", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, _, _ := newSetupDeps(tenant) + + _, err := setupBusiness(tenant, businessOptions("nobody@acme.com"), deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.Equal(t, `No user matches "nobody@acme.com"`, err.Error()) + assert.Empty(t, tenant.Clients()) + assert.Empty(t, tenant.Grants()) + assert.Empty(t, tenant.VaultConnections()) + assert.Empty(t, tenant.Portals()) + }) + + t.Run("a cancel at the test user question stops before anything is created", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, _, prompter := interactiveDeps(tenant, answers{}) + prompter.AskFunc = func(string, string, string) (string, error) { return "", portals.ErrCancelled } + + _, err := setupBusiness(tenant, businessOptions(""), deps) + + require.ErrorIs(t, err, portals.ErrCancelled) + assert.Empty(t, tenant.Clients()) + assert.Empty(t, tenant.Portals()) + }) + + t.Run("it asks for the test user before the portal is created", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|user1", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme-corp", "auth0|user1") + seedAdminRole(tenant) + tenant.AssignRole("org_1", "auth0|user1", "rol_admin") + + deps, _, prompter := interactiveDeps(tenant, answers{}) + + type asked struct { + question string + portalsBefore int + } + + var questions []asked + + prompter.AskFunc = func(question, _, _ string) (string, error) { + questions = append(questions, asked{question, len(tenant.Portals())}) + return "jane@acme.com", nil + } + + result, err := setupBusiness(tenant, businessOptions(""), deps) + + require.NoError(t, err) + assert.Equal(t, []asked{{"Enter the test user's email or user ID (press Enter to skip)", 0}}, questions) + assert.Len(t, tenant.Portals(), 1) + assert.Equal(t, "org_1", result.Organization.ID) + }) +} + +func TestSetup_BusinessPortalWithoutATestUser(t *testing.T) { + t.Parallel() + + t.Run("skipping the test user links to the organization ID that was typed", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, reporter, _ := interactiveDeps(tenant, answers{asks: []string{"", "org_42"}}) + + result, err := setupBusiness(tenant, businessOptions(""), deps) + + require.NoError(t, err) + assert.Equal(t, "https://acme.us.auth0.com/portals/acme-admin?organization=org_42", result.Portal.URL) + assert.Nil(t, result.Organization, "no membership is checked without a user") + assert.False(t, reporter.Contains("info", "Replace ")) + }) + + t.Run("leaving the organization empty keeps the placeholder in the link", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, reporter, prompter := interactiveDeps(tenant, answers{}) + prompter.AskFunc = func(question, defaultValue, _ string) (string, error) { + if strings.Contains(question, "test user") { + return "", nil + } + + return defaultValue, nil + } + + result, err := setupBusiness(tenant, businessOptions(""), deps) + + require.NoError(t, err) + assert.Equal(t, "https://acme.us.auth0.com/portals/acme-admin?organization=", result.Portal.URL) + assert.True(t, reporter.Contains("info", "Replace in the link with the organization ID to test with.")) + assert.True(t, reporter.Contains("info", "Business portals only work in the context of an organization")) + assert.True(t, reporter.Contains("info", "Without a test user, the link needs an organization.")) + }) +} + +func TestSetup_ChoosingTheTestUser(t *testing.T) { + t.Parallel() + + t.Run("an email lookup keeps only the exact address, not a longer one that contains it", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedUser(tenant, "auth0|b", "jane@acme.com.au") + seedOrganization(tenant, "org_1", "acme", "auth0|a") + deps, _, _ := newSetupDeps(tenant) + + result, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + require.NoError(t, err) + assert.Equal(t, "org_1", result.Organization.ID) + }) + + t.Run("an email that matches several users lets the person pick one", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedUser(tenant, "google-oauth2|b", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "google-oauth2|b") + deps, _, _ := interactiveDeps(tenant, answers{chooses: []string{"google-oauth2|b"}, confirms: []bool{false}}) + + result, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + require.NoError(t, err) + assert.Equal(t, "org_1", result.Organization.ID) + }) + + t.Run("from a list of users with the same email, trying another looks the user up again", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedUser(tenant, "auth0|b", "jane@acme.com") + seedUser(tenant, "auth0|other", "other@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|other") + deps, _, _ := interactiveDeps(tenant, answers{ + asks: []string{"auth0|other"}, + chooses: []string{"Try another"}, + confirms: []bool{false}, + }) + + result, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + require.NoError(t, err) + assert.Equal(t, "org_1", result.Organization.ID) + }) + + t.Run("an unknown email asks again instead of stopping", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|a") + deps, reporter, _ := interactiveDeps(tenant, answers{asks: []string{"jane@acme.com"}, confirms: []bool{false}}) + + result, err := setupBusiness(tenant, businessOptions("nobody@acme.com"), deps) + + require.NoError(t, err) + assert.Equal(t, "org_1", result.Organization.ID) + assert.True(t, reporter.Contains("warn", `No user matches "nobody@acme.com".`)) + }) + + t.Run("a user ID that matches nobody asks again instead of stopping", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|a") + deps, _, _ := interactiveDeps(tenant, answers{asks: []string{"auth0|a"}, confirms: []bool{false}}) + + result, err := setupBusiness(tenant, businessOptions("auth0|nobody"), deps) + + require.NoError(t, err) + assert.Equal(t, "org_1", result.Organization.ID) + }) + + t.Run("without a terminal an email that matches several users stops with the IDs to use", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedUser(tenant, "auth0|b", "jane@acme.com") + deps, _, _ := newSetupDeps(tenant) + + _, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.Equal(t, `More than one user has the email "jane@acme.com"`, err.Error()) + assert.Equal(t, []string{"Rerun with the user ID instead: auth0|a, auth0|b"}, portals.FixesOf(err)) + }) +} + +func TestSetup_ChoosingTheOrganization(t *testing.T) { + t.Parallel() + + t.Run("a user in several organizations can pick the one the portal is for", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|a") + seedOrganization(tenant, "org_2", "labs", "auth0|a") + deps, _, _ := interactiveDeps(tenant, answers{chooses: []string{"labs"}, confirms: []bool{false}}) + + result, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.Equal(t, "org_2", result.Organization.ID) + }) + + t.Run("an organization can be given by name when the user has several", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|a") + seedOrganization(tenant, "org_2", "labs", "auth0|a") + deps, _, _ := newSetupDeps(tenant) + + options := businessOptions("auth0|a") + options.Organization = "labs" + + result, err := setupBusiness(tenant, options, deps) + + require.NoError(t, err) + assert.Equal(t, "org_2", result.Organization.ID) + }) + + t.Run("without a terminal, several organizations stop with the IDs to choose from", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|a") + seedOrganization(tenant, "org_2", "labs", "auth0|a") + deps, _, _ := newSetupDeps(tenant) + + _, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.Equal(t, "jane@acme.com belongs to 2 organizations", err.Error()) + assert.Equal(t, []string{"Rerun with --organization : org_1, org_2"}, portals.FixesOf(err)) + }) + + t.Run("without a terminal, an organization the user is not in stops with the command to add them", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|a") + seedOrganization(tenant, "org_2", "labs") + deps, _, _ := newSetupDeps(tenant) + + options := businessOptions("auth0|a") + options.Organization = "org_2" + + _, err := setupBusiness(tenant, options, deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.Contains(t, strings.Join(portals.FixesOf(err), " "), "organizations/org_2/members") + }) + + t.Run("without a terminal, a user in no organization stops with the command to add them", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|user1", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme-corp") + deps, _, _ := newSetupDeps(tenant) + + _, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.Contains(t, portals.FixesOf(err)[0], `auth0 api post "organizations//members"`) + assert.Empty(t, tenant.Portals(), "no portal is created before the test user is checked") + }) + + t.Run("a user in no organization can join an existing one after confirmation", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme") + deps, _, _ := interactiveDeps(tenant, answers{chooses: []string{"acme"}, confirms: []bool{true, false}}) + + result, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.Equal(t, "org_1", result.Organization.ID) + assert.Contains(t, tenant.Members("org_1"), "auth0|a", "the user was added as a member") + }) + + t.Run("declining to add a user to an organization adds nobody", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme") + deps, _, _ := interactiveDeps(tenant, answers{chooses: []string{"acme"}, confirms: []bool{false}}) + + _, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.NotContains(t, tenant.Members("org_1"), "auth0|a") + assert.Empty(t, tenant.Portals()) + }) + + t.Run("an organization with no enabled connection is refused", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|user1", "jane@acme.com") + tenant.AddOrganization(portals.Organization{ID: "org_1", Name: "acme-corp"}, "auth0|user1") + deps, _, _ := newSetupDeps(tenant) + + _, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.Contains(t, err.Error(), "no enabled connections") + assert.Contains(t, portals.FixesOf(err)[0], `organizations/org_1/enabled_connections`) + }) +} + +func TestSetup_CreatingAnOrganization(t *testing.T) { + t.Parallel() + + t.Run("a user with no organization can create one, with a database connection and themselves as a member", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedDatabase(tenant) + deps, _, _ := interactiveDeps(tenant, answers{ + asks: []string{"Acme Admins", "acme-admins"}, + chooses: []string{"None of these"}, + confirms: []bool{true, false}, + }) + + result, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + require.NoError(t, err) + + organizations := tenant.Organizations() + require.Len(t, organizations, 1) + assert.Equal(t, "Acme Admins", organizations[0].DisplayName) + assert.Equal(t, organizations[0].ID, result.Organization.ID) + assert.Contains(t, tenant.Members(organizations[0].ID), "auth0|a") + + connections, err := tenant.ListOrganizationConnections(context.Background(), organizations[0].ID) + require.NoError(t, err) + assert.Equal(t, []string{"con_db"}, connections) + }) + + t.Run("declining the confirmation creates nothing", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedDatabase(tenant) + deps, _, _ := interactiveDeps(tenant, answers{ + asks: []string{"Acme Admins", "acme-admins"}, + chooses: []string{"None of these"}, + confirms: []bool{false}, + }) + + _, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.Equal(t, "No organization was created", err.Error()) + assert.Empty(t, tenant.Organizations()) + }) + + t.Run("a taken name asks for another name, with a suggestion", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + tenant.AddOrganization(portals.Organization{ID: "org_taken", Name: "acme-admins", DisplayName: "Acme"}) + seedDatabase(tenant) + deps, reporter, prompter := interactiveDeps(tenant, answers{ + asks: []string{"Acme Admins", "acme-admins", "acme-admins-2"}, + chooses: []string{"None of these"}, + confirms: []bool{true, true, false}, + }) + + var suggestions []string + + scripted := prompter.AskFunc + prompter.AskFunc = func(question, defaultValue, example string) (string, error) { + suggestions = append(suggestions, question+"="+defaultValue) + return scripted(question, defaultValue, example) + } + + result, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + require.NoError(t, err) + assert.True(t, reporter.Contains("warn", `The organization name "acme-admins" is already used in this tenant.`)) + assert.Equal(t, []string{ + "Organization display name=", + "Organization name=acme-admins", + "Organization name=acme-admins-2", + }, suggestions) + + for _, organization := range tenant.Organizations() { + if organization.ID == result.Organization.ID { + assert.Equal(t, "acme-admins-2", organization.Name) + assert.Equal(t, "Acme Admins", organization.DisplayName) + } + } + }) + + t.Run("a name with invalid characters asks again", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedDatabase(tenant) + deps, reporter, _ := interactiveDeps(tenant, answers{ + asks: []string{"Acme Admins", "Acme Admins!", "acme-admins"}, + chooses: []string{"None of these"}, + confirms: []bool{true, false}, + }) + + _, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + require.NoError(t, err) + assert.Equal(t, "acme-admins", tenant.Organizations()[0].Name) + assert.True(t, reporter.Contains("error", "Use lowercase letters, numbers, hyphens and underscores, up to 50 characters.")) + }) + + t.Run("a conflict after the organization exists is not treated as a name clash", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedDatabase(tenant) + deps, _, _ := interactiveDeps(tenant, answers{ + asks: []string{"Acme Corp", "acme-corp"}, + chooses: []string{"None of these"}, + confirms: []bool{true}, + }) + deps.API = conflictingConnectionTenant{Tenant: tenant} + + _, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.Error(t, err) + assert.True(t, portals.IsStatus(err, http.StatusConflict)) + assert.Len(t, tenant.Organizations(), 1, "asking for a new name would have created a second organization") + }) + + t.Run("a tenant with no database connection stops with what to do", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + deps, _, _ := interactiveDeps(tenant, answers{ + asks: []string{"Acme Admins", "acme-admins"}, + chooses: []string{"None of these"}, + confirms: []bool{true}, + }) + + _, err := setupBusiness(tenant, businessOptions("jane@acme.com"), deps) + + var access *portals.BusinessAccessError + require.ErrorAs(t, err, &access) + assert.Equal(t, "This tenant has no database connection to sign in with", err.Error()) + }) +} + +// conflictingConnectionTenant is a tenant where enabling a connection on an organization conflicts. +type conflictingConnectionTenant struct { + *portalstest.Tenant +} + +func (conflictingConnectionTenant) EnableOrganizationConnection(context.Context, string, string) error { + return &portals.APIError{Status: http.StatusConflict, Message: "The connection is already enabled."} +} + +func TestSetup_TheRoleOfTheTestUser(t *testing.T) { + t.Parallel() + + member := func() *portalstest.Tenant { + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|a") + + return tenant + } + + t.Run("a role that grants every organization scope is accepted without an offer", func(t *testing.T) { + t.Parallel() + + tenant := member() + seedAdminRole(tenant) + tenant.AssignRole("org_1", "auth0|a", "rol_admin") + deps, reporter, prompter := interactiveDeps(tenant, answers{}) + + result, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.True(t, result.Organization.HasRole) + assert.Empty(t, prompter.Questions()) + assert.False(t, reporter.Contains("warn", "lacks")) + }) + + t.Run("a user with no role can be given one after confirmation", func(t *testing.T) { + t.Parallel() + + tenant := member() + seedAdminRole(tenant) + deps, _, _ := interactiveDeps(tenant, answers{chooses: []string{"Organization - Admin"}, confirms: []bool{true}}) + + result, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.True(t, result.Organization.HasRole) + + roles, err := tenant.ListMemberRoles(context.Background(), "org_1", "auth0|a") + require.NoError(t, err) + assert.Equal(t, []portals.Role{{ID: "rol_admin", Name: "Organization - Admin"}}, roles) + }) + + t.Run("declining the role keeps going, and the user has no role", func(t *testing.T) { + t.Parallel() + + tenant := member() + seedAdminRole(tenant) + deps, _, _ := interactiveDeps(tenant, answers{confirms: []bool{false}}) + + result, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.False(t, result.Organization.HasRole) + + roles, err := tenant.ListMemberRoles(context.Background(), "org_1", "auth0|a") + require.NoError(t, err) + assert.Empty(t, roles) + }) + + t.Run("a role that lacks organization scopes is a warning that names them", func(t *testing.T) { + t.Parallel() + + tenant := member() + tenant.AddRole(portals.Role{ID: "rol_reader", Name: "Organization - Reader"}, permissionsOf([]string{"read:my_org:details", "read:my_org:domains"})...) + tenant.AssignRole("org_1", "auth0|a", "rol_reader") + deps, reporter, _ := newSetupDeps(tenant) + + result, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.False(t, result.Organization.HasRole) + assert.True(t, reporter.Contains("warn", "lacks read permissions")) + assert.True(t, reporter.Contains("warn", "read:my_org:configuration")) + assert.True(t, reporter.Contains("warn", "lacks write permissions")) + assert.True(t, reporter.Contains("warn", "update:my_org:details")) + assert.True(t, reporter.Contains("warn", "delete:my_org:domains")) + }) + + t.Run("a role with read but no write scopes warns that changes cannot be saved", func(t *testing.T) { + t.Parallel() + + tenant := member() + + var reads []string + + for _, scope := range portals.MyOrganizationScopes() { + if strings.HasPrefix(scope, "read:") { + reads = append(reads, scope) + } + } + + tenant.AddRole(portals.Role{ID: "rol_viewer", Name: "Organization - Viewer"}, permissionsOf(reads)...) + tenant.AssignRole("org_1", "auth0|a", "rol_viewer") + deps, reporter, _ := newSetupDeps(tenant) + + _, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.True(t, reporter.Contains("warn", "lacks write permissions")) + assert.False(t, reporter.Contains("warn", "lacks read permissions")) + assert.True(t, reporter.Contains("warn", "changes cannot be saved")) + }) + + t.Run("with no role that grants the missing permissions the offer is to create the portal role, and declining keeps the user without one", func(t *testing.T) { + t.Parallel() + + tenant := member() + tenant.AddRole(portals.Role{ID: "rol_reader", Name: "Organization - Reader"}, permissionsOf([]string{"read:my_org:details"})...) + deps, _, _ := interactiveDeps(tenant, answers{confirms: []bool{false}}) + + result, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.False(t, result.Organization.HasRole) + + for _, role := range tenant.Roles() { + assert.NotEqual(t, "Universal Portals - Admin", role.Name) + } + }) + + t.Run("accepting the offer creates Universal Portals - Admin with only the portal permissions and assigns it", func(t *testing.T) { + t.Parallel() + + tenant := member() + deps, _, _ := interactiveDeps(tenant, answers{confirms: []bool{true}}) + + result, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.True(t, result.Organization.HasRole) + + var created portals.Role + + for _, role := range tenant.Roles() { + if role.Name == "Universal Portals - Admin" { + created = role + } + } + + require.NotEmpty(t, created.ID, "the role was created") + + names := make([]string, 0) + for _, permission := range tenant.RolePermissions(created.ID) { + names = append(names, permission.Name) + assert.Equal(t, "https://acme.us.auth0.com/my-org/", permission.ResourceServerIdentifier) + } + + assert.ElementsMatch(t, portals.MyOrganizationScopes(), names) + + roles, err := tenant.ListMemberRoles(context.Background(), "org_1", "auth0|a") + require.NoError(t, err) + assert.Equal(t, []portals.Role{created}, roles) + }) + + t.Run("the portal role is reused, and only its missing permissions are added", func(t *testing.T) { + t.Parallel() + + tenant := member() + tenant.AddRole(portals.Role{ID: "rol_portal", Name: "Universal Portals - Admin"}, permissionsOf([]string{"read:my_org:details"})...) + deps, _, _ := interactiveDeps(tenant, answers{confirms: []bool{true, true}, chooses: []string{`Create "Universal Portals - Admin"`}}) + + result, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + assert.True(t, result.Organization.HasRole) + assert.Len(t, tenant.Roles(), 1, "no second role with the same name") + assert.Len(t, tenant.RolePermissions("rol_portal"), len(portals.MyOrganizationScopes()), "the permission it had is not added again") + }) +} + +// rendezvousTenant makes the permission reads of the roles named "rol_slow_*" wait for each other: a +// read returns once another one is in flight, or after the timeout. Reads sent one after the other +// can never meet, so the tenant tells whether they went out together. +type rendezvousTenant struct { + *portalstest.Tenant + + inFlight atomic.Int32 + met chan struct{} + once sync.Once +} + +func (r *rendezvousTenant) ListRolePermissions(ctx context.Context, roleID string) ([]portals.Permission, error) { + if strings.HasPrefix(roleID, "rol_slow_") { + if r.inFlight.Add(1) > 1 { + r.once.Do(func() { close(r.met) }) + } + + select { + case <-r.met: + case <-time.After(500 * time.Millisecond): + } + + r.inFlight.Add(-1) + } + + return r.Tenant.ListRolePermissions(ctx, roleID) +} + +// A tenant can have many roles, and looking for the ones that fix a warning reads the permissions of +// each, so the reads go out together like the other reads of setup and the audit. +func TestSetup_TheRolesThatFixAWarningAreReadTogether(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedUser(tenant, "auth0|a", "jane@acme.com") + seedOrganization(tenant, "org_1", "acme", "auth0|a") + tenant.AddRole(portals.Role{ID: "rol_reader", Name: "Organization - Reader"}, permissionsOf([]string{"read:my_org:details"})...) + tenant.AssignRole("org_1", "auth0|a", "rol_reader") + + for _, id := range []string{"rol_slow_1", "rol_slow_2", "rol_slow_3"} { + tenant.AddRole(portals.Role{ID: id, Name: "Other " + id}, permissionsOf([]string{"read:my_org:details"})...) + } + + gated := &rendezvousTenant{Tenant: tenant, met: make(chan struct{})} + deps, _, _ := interactiveDeps(tenant, answers{confirms: []bool{false}}) + deps.API = gated + + _, err := setupBusiness(tenant, businessOptions("auth0|a"), deps) + + require.NoError(t, err) + + select { + case <-gated.met: + default: + t.Fatal("the permissions of the roles were read one after the other") + } +} diff --git a/internal/portals/dashboard.go b/internal/portals/dashboard.go new file mode 100644 index 000000000..36291bb30 --- /dev/null +++ b/internal/portals/dashboard.go @@ -0,0 +1,68 @@ +package portals + +import "strings" + +// A tenantLocation says where the dashboard of a tenant is. +type tenantLocation struct { + base string + region string + name string +} + +// locate reads the dashboard location from the domain of a tenant. For example "acme.us.auth0.com" +// is https://manage.auth0.com, region "us" and name "acme". A tenant without a region code is +// treated as "us". +func locate(tenantDomain string) tenantLocation { + parts := strings.Split(tenantDomain, ".") + hasRegion := len(parts) >= 4 && len(parts[1]) == 2 + + service := parts[1:] + if hasRegion { + service = parts[2:] + } + + region := "us" + if hasRegion { + region = parts[1] + } + + return tenantLocation{base: "https://manage." + strings.Join(service, "."), region: region, name: parts[0]} +} + +func dashboardRoot(tenantDomain string) string { + location := locate(tenantDomain) + + return location.base + "/dashboard/" + location.region + "/" + location.name +} + +// formsRoot is where the Forms app lives, under the path of the tenant. Forms and flows are there. +func formsRoot(tenantDomain string) string { + location := locate(tenantDomain) + + return "https://forms.auth0.com/tenants/" + location.region + "/" + location.name +} + +// ApplicationURL is the settings page of an application in the dashboard. +func ApplicationURL(tenantDomain, clientID string) string { + return dashboardRoot(tenantDomain) + "/applications/" + clientID + "/settings" +} + +// PortalEditURL is the page where a portal is edited in the dashboard. +func PortalEditURL(tenantDomain, portalID string) string { + return dashboardRoot(tenantDomain) + "/universal-portals/" + portalID + "/edit" +} + +// FormEditURL is the page where a form is edited. +func FormEditURL(tenantDomain, formID string) string { + return formsRoot(tenantDomain) + "/forms/" + formID + "/edit" +} + +// FlowEditURL is the page where a flow is edited. +func FlowEditURL(tenantDomain, flowID string) string { + return formsRoot(tenantDomain) + "/flows/" + flowID + "/edit" +} + +// VaultAppsURL opens the list of the AUTH0 app type, because vault connections have no page of their own. +func VaultAppsURL(tenantDomain string) string { + return formsRoot(tenantDomain) + "/vault/apps/AUTH0/edit" +} diff --git a/internal/portals/dashboard_test.go b/internal/portals/dashboard_test.go new file mode 100644 index 000000000..5b8ea315e --- /dev/null +++ b/internal/portals/dashboard_test.go @@ -0,0 +1,71 @@ +package portals_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/portals" +) + +func TestDashboardURLs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tenant string + got func(tenant string) string + want string + }{ + { + name: "an application opens its settings, on the host of the region", + tenant: "acme.eu.auth0.com", + got: func(tenant string) string { return portals.ApplicationURL(tenant, "abc123") }, + want: "https://manage.auth0.com/dashboard/eu/acme/applications/abc123/settings", + }, + { + name: "a tenant without a region code is treated as us", + tenant: "dev-example.auth0.com", + got: func(tenant string) string { return portals.ApplicationURL(tenant, "abc123") }, + want: "https://manage.auth0.com/dashboard/us/dev-example/applications/abc123/settings", + }, + { + name: "a tenant in another cloud keeps its service domain", + tenant: "acme.us.example-cloud.com", + got: func(tenant string) string { return portals.ApplicationURL(tenant, "abc123") }, + want: "https://manage.example-cloud.com/dashboard/us/acme/applications/abc123/settings", + }, + { + name: "a portal is edited in the dashboard", + tenant: "acme.us.auth0.com", + got: func(tenant string) string { return portals.PortalEditURL(tenant, "up_1") }, + want: "https://manage.auth0.com/dashboard/us/acme/universal-portals/up_1/edit", + }, + { + name: "a form is edited in the Forms app", + tenant: "acme.us.auth0.com", + got: func(tenant string) string { return portals.FormEditURL(tenant, "ap_1") }, + want: "https://forms.auth0.com/tenants/us/acme/forms/ap_1/edit", + }, + { + name: "a flow is edited in the Forms app", + tenant: "acme.us.auth0.com", + got: func(tenant string) string { return portals.FlowEditURL(tenant, "af_1") }, + want: "https://forms.auth0.com/tenants/us/acme/flows/af_1/edit", + }, + { + name: "the vault opens the list of the AUTH0 app type", + tenant: "acme.us.auth0.com", + got: portals.VaultAppsURL, + want: "https://forms.auth0.com/tenants/us/acme/vault/apps/AUTH0/edit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, tt.got(tt.tenant)) + }) + } +} diff --git a/internal/portals/data.go b/internal/portals/data.go new file mode 100644 index 000000000..bdddb2799 --- /dev/null +++ b/internal/portals/data.go @@ -0,0 +1,142 @@ +package portals + +import ( + "embed" + "encoding/json" + "fmt" + "strings" +) + +// The forms and the pages of the portals are data: the JSON in data/ holds them as the Management +// API takes them. A few values are only known when setup runs, and stand in them as $NAME. +// +//go:embed data/forms/*.json data/templates/*.json +var dataFiles embed.FS + +// FormKey identifies one of the forms that the pages of a portal use. +type FormKey string + +const ( + // FormPersonalInfo updates the profile of the user. + FormPersonalInfo FormKey = "personalInfo" + // FormCommunicationPreferences updates the communication preferences of the user. + FormCommunicationPreferences FormKey = "communicationPreferences" + // FormPrivacyConsent updates the privacy settings of the user. + FormPrivacyConsent FormKey = "privacyConsent" +) + +// formKeys are the forms in the order setup creates them. +var formKeys = []FormKey{FormPersonalInfo, FormCommunicationPreferences, FormPrivacyConsent} + +// formNames are the names of the forms in the tenant. +var formNames = map[FormKey]string{ + FormPersonalInfo: "Profile update (Universal Portals)", + FormPrivacyConsent: "Privacy settings (Universal Portals)", + FormCommunicationPreferences: "Marketing communication preferences (Universal Portals)", +} + +var formFiles = map[FormKey]string{ + FormPersonalInfo: "data/forms/personal_info.json", + FormCommunicationPreferences: "data/forms/communication_preferences.json", + FormPrivacyConsent: "data/forms/privacy_consent.json", +} + +// FormName returns the name of a form in the tenant. +func FormName(key FormKey) string { + return formNames[key] +} + +// formDefinition is a form to import, with the document the API takes. +type formDefinition struct { + key FormKey + name string + document map[string]any +} + +// buildForms returns the forms every portal uses. Each of them writes to the signed-in user through +// the vault connection. +func buildForms(vaultConnectionID string) ([]formDefinition, error) { + definitions := make([]formDefinition, 0, len(formKeys)) + + for _, key := range formKeys { + raw, err := dataFiles.ReadFile(formFiles[key]) + if err != nil { + return nil, fmt.Errorf("read the form %q: %w", key, err) + } + + text, err := substitute(string(raw), map[string]string{"$VAULT_CONNECTION_ID": vaultConnectionID}) + if err != nil { + return nil, fmt.Errorf("build the form %q: %w", key, err) + } + + var document map[string]any + if err := json.Unmarshal([]byte(text), &document); err != nil { + return nil, fmt.Errorf("read the form %q: %w", key, err) + } + + definitions = append(definitions, formDefinition{key: key, name: formNames[key], document: document}) + } + + return definitions, nil +} + +// formIDs are the IDs of the forms in the tenant. +type formIDs map[FormKey]string + +// portalTemplate is the pages of a kind of portal. +type portalTemplate struct { + DefaultPage string `json:"default_page"` + Sidebar []SidebarComponent `json:"sidebar"` + Pages []Page `json:"pages"` +} + +var templateFiles = map[Template]string{ + TemplateConsumer: "data/templates/consumer.json", + TemplateBusiness: "data/templates/business.json", +} + +// templateFor returns the pages of a kind of portal, with the forms it uses. +func templateFor(template Template, forms formIDs) (portalTemplate, error) { + path, known := templateFiles[template] + if !known { + return portalTemplate{}, fmt.Errorf("unknown template %q", template) + } + + raw, err := dataFiles.ReadFile(path) + if err != nil { + return portalTemplate{}, fmt.Errorf("read the %s template: %w", template, err) + } + + text, err := substitute(string(raw), map[string]string{ + "$FORM_PERSONAL_INFO": forms[FormPersonalInfo], + "$FORM_PRIVACY_CONSENT": forms[FormPrivacyConsent], + "$FORM_COMMUNICATION_PREFERENCES": forms[FormCommunicationPreferences], + }) + if err != nil { + return portalTemplate{}, fmt.Errorf("build the %s template: %w", template, err) + } + + var built portalTemplate + if err := json.Unmarshal([]byte(text), &built); err != nil { + return portalTemplate{}, fmt.Errorf("read the %s template: %w", template, err) + } + + return built, nil +} + +// substitute replaces the placeholders in JSON text with values that are valid inside a JSON string. +func substitute(text string, values map[string]string) (string, error) { + pairs := make([]string, 0, len(values)*2) + + for placeholder, value := range values { + encoded, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("encode the value of %s: %w", placeholder, err) + } + + // A placeholder stands inside quotes, so the quotes of the encoded value are dropped. + pairs = append(pairs, placeholder, strings.Trim(string(encoded), `"`)) + } + + return strings.NewReplacer(pairs...).Replace(text), nil +} diff --git a/internal/portals/data/forms/communication_preferences.json b/internal/portals/data/forms/communication_preferences.json new file mode 100644 index 000000000..db2af0b84 --- /dev/null +++ b/internal/portals/data/forms/communication_preferences.json @@ -0,0 +1,115 @@ +{ + "version": "4.0.0", + "form": { + "name": "Marketing communication preferences (Universal Portals)", + "languages": { + "primary": "en" + }, + "nodes": [ + { + "id": "step_4Td2", + "type": "STEP", + "coordinates": { + "x": 217, + "y": -218 + }, + "config": { + "components": [ + { + "id": "communication_preferences", + "category": "FIELD", + "type": "CHOICE", + "label": "Communication preferences", + "required": false, + "sensitive": false, + "config": { + "multiple": true, + "options": [ + { + "label": "Product Announcements", + "value": "Product Announcements" + }, + { + "label": "Featured Content", + "value": "Featured Content" + }, + { + "label": "Digest News", + "value": "Digest News" + }, + { + "label": "Events", + "value": "Events" + } + ] + } + }, + { + "id": "next_button_US5N", + "category": "BLOCK", + "type": "NEXT_BUTTON", + "config": { + "text": "Update" + } + } + ], + "next_node": "flow_etvH" + } + }, + { + "id": "flow_etvH", + "type": "FLOW", + "coordinates": { + "x": 988, + "y": 9 + }, + "config": { + "flow_id": "#FLOW-1#", + "next_node": "$ending" + } + } + ], + "start": { + "next_node": "step_4Td2", + "coordinates": { + "x": -25, + "y": -98 + } + }, + "ending": { + "resume_flow": true, + "coordinates": { + "x": 1194, + "y": -59 + } + } + }, + "flows": { + "#FLOW-1#": { + "name": "Update preferences", + "actions": [ + { + "id": "update_user_HitP", + "type": "AUTH0", + "action": "UPDATE_USER", + "allow_failure": false, + "mask_output": false, + "params": { + "connection_id": "#CONN-1#", + "user_id": "{{context.user.user_id}}", + "changes": { + "app_metadata": { + "communication_preferences": "{{fields.communication_preferences}}" + } + } + } + } + ] + } + }, + "connections": { + "#CONN-1#": { + "id": "$VAULT_CONNECTION_ID" + } + } +} diff --git a/internal/portals/data/forms/personal_info.json b/internal/portals/data/forms/personal_info.json new file mode 100644 index 000000000..70aa88091 --- /dev/null +++ b/internal/portals/data/forms/personal_info.json @@ -0,0 +1,142 @@ +{ + "version": "4.0.0", + "form": { + "name": "Profile update (Universal Portals)", + "languages": { + "primary": "en" + }, + "nodes": [ + { + "id": "step_BL3V", + "type": "STEP", + "coordinates": { + "x": 346, + "y": -221 + }, + "config": { + "components": [ + { + "id": "full_name", + "category": "FIELD", + "type": "TEXT", + "label": "Full name", + "required": false, + "sensitive": false, + "config": { + "multiline": false + } + }, + { + "id": "job_title", + "category": "FIELD", + "type": "TEXT", + "label": "Job title", + "required": false, + "sensitive": false, + "config": { + "multiline": false + } + }, + { + "id": "mobile_number", + "category": "FIELD", + "type": "TEL", + "label": "Mobile number", + "required": false, + "sensitive": false, + "config": { + "country_picker": true + } + }, + { + "id": "date_of_birth", + "category": "FIELD", + "type": "DATE", + "label": "Date of birth", + "required": false, + "sensitive": false, + "config": { + "format": "DATE" + } + }, + { + "id": "linkedin", + "category": "FIELD", + "type": "URL", + "label": "LinkedIn", + "required": false, + "sensitive": false + }, + { + "id": "next_button_UmEY", + "category": "BLOCK", + "type": "NEXT_BUTTON", + "config": { + "text": "Update" + } + } + ], + "next_node": "flow_DcHK" + } + }, + { + "id": "flow_DcHK", + "type": "FLOW", + "coordinates": { + "x": 988, + "y": 9 + }, + "config": { + "flow_id": "#FLOW-1#", + "next_node": "$ending" + } + } + ], + "start": { + "next_node": "step_BL3V", + "coordinates": { + "x": 0, + "y": 0 + } + }, + "ending": { + "resume_flow": true, + "coordinates": { + "x": 1367, + "y": -7 + } + } + }, + "flows": { + "#FLOW-1#": { + "name": "Update profile", + "actions": [ + { + "id": "update_user_v4C0", + "type": "AUTH0", + "action": "UPDATE_USER", + "allow_failure": false, + "mask_output": false, + "params": { + "connection_id": "#CONN-1#", + "user_id": "{{context.user.user_id}}", + "changes": { + "user_metadata": { + "linkedin": "{{fields.linkedin}}", + "full_name": "{{fields.full_name}}", + "job_title": "{{fields.job_title}}", + "date_of_birth": "{{fields.date_of_birth}}", + "mobile_number": "{{fields.mobile_number.international_number}}" + } + } + } + } + ] + } + }, + "connections": { + "#CONN-1#": { + "id": "$VAULT_CONNECTION_ID" + } + } +} diff --git a/internal/portals/data/forms/privacy_consent.json b/internal/portals/data/forms/privacy_consent.json new file mode 100644 index 000000000..97f8feb9b --- /dev/null +++ b/internal/portals/data/forms/privacy_consent.json @@ -0,0 +1,137 @@ +{ + "version": "4.0.0", + "form": { + "name": "Privacy settings (Universal Portals)", + "languages": { + "primary": "en" + }, + "nodes": [ + { + "id": "step_y0Ri", + "type": "STEP", + "coordinates": { + "x": 500, + "y": 0 + }, + "config": { + "components": [ + { + "id": "data_sharing", + "category": "FIELD", + "type": "BOOLEAN", + "label": "Data sharing", + "hint": "

Allow app usage data to improve features. Learn more.

", + "required": true, + "sensitive": false, + "config": { + "default_value": false + } + }, + { + "id": "profile_visibility", + "category": "FIELD", + "type": "BOOLEAN", + "label": "Profile visibility", + "hint": "

Show my profile in search results. Learn more.

", + "required": true, + "sensitive": false, + "config": { + "default_value": false + } + }, + { + "id": "location_tracking", + "category": "FIELD", + "type": "BOOLEAN", + "label": "Location tracking", + "hint": "

Allow location tracking for personalized recommendations. Learn more.

", + "required": true, + "sensitive": false, + "config": { + "default_value": false + } + }, + { + "id": "ad_personalization", + "category": "FIELD", + "type": "BOOLEAN", + "label": "Ad personalization", + "hint": "

Use my data to personalize ads. Learn more.

", + "required": true, + "sensitive": false, + "config": { + "default_value": false + } + }, + { + "id": "next_button_aB8e", + "category": "BLOCK", + "type": "NEXT_BUTTON", + "config": { + "text": "Update" + } + } + ], + "next_node": "flow_AB1r" + } + }, + { + "id": "flow_AB1r", + "type": "FLOW", + "coordinates": { + "x": 988, + "y": 9 + }, + "config": { + "flow_id": "#FLOW-1#", + "next_node": "$ending" + } + } + ], + "start": { + "next_node": "step_y0Ri", + "coordinates": { + "x": 202, + "y": 247 + } + }, + "ending": { + "resume_flow": true, + "coordinates": { + "x": 1445, + "y": 247 + } + } + }, + "flows": { + "#FLOW-1#": { + "name": "Update privacy settings", + "actions": [ + { + "id": "update_user_uOZW", + "type": "AUTH0", + "action": "UPDATE_USER", + "allow_failure": false, + "mask_output": false, + "params": { + "connection_id": "#CONN-1#", + "user_id": "{{context.user.user_id}}", + "changes": { + "app_metadata": { + "data_sharing": "{{fields.data_sharing}}", + "location_tracking": "{{fields.location_tracking}}", + "ad_personalization": "{{fields.ad_personalization}}", + "profile_visibility": "{{fields.profile_visibility}}" + } + } + } + } + ] + } + }, + "connections": { + "#CONN-1#": { + "id": "$VAULT_CONNECTION_ID" + } + } +} diff --git a/internal/portals/data/templates/business.json b/internal/portals/data/templates/business.json new file mode 100644 index 000000000..b3694d647 --- /dev/null +++ b/internal/portals/data/templates/business.json @@ -0,0 +1,149 @@ +{ + "default_page": "organization", + "sidebar": [ + { + "type": "sidebar:component:auth0:internal_link:v2", + "config": { + "label": "Organization", + "page_slug": "organization", + "icon": "building-2" + } + }, + { + "type": "sidebar:component:auth0:internal_link:v2", + "config": { + "label": "Domains", + "page_slug": "domains", + "icon": "globe" + } + }, + { + "type": "sidebar:component:auth0:internal_link:v2", + "config": { + "label": "Security", + "page_slug": "security", + "icon": "shield-check" + } + }, + { + "type": "sidebar:component:auth0:internal_link:v2", + "config": { + "label": "Preferences", + "page_slug": "preferences", + "icon": "sliders-horizontal" + } + } + ], + "pages": [ + { + "title": "Organization", + "slug": "organization", + "components": [ + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Organization details", + "description": "Update your organization's name and other details visible to its members.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:my_organization:details_edit" + } + ] + } + } + ] + }, + { + "title": "Domains", + "slug": "domains", + "components": [ + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Verified domains", + "description": "Add and verify the domains that your organization members sign in with.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:my_organization:domain_table" + } + ] + } + } + ] + }, + { + "title": "Security", + "slug": "security", + "components": [ + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Multi-factor authentication", + "description": "Add an extra layer of protection to your account by requiring a second verification step each time you sign in.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:my_account:mfa_management" + } + ] + } + }, + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Passkeys", + "description": "Use your fingerprint, face, or screen lock instead of a password to sign in quickly and more securely.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:my_account:passkey_management" + } + ] + } + } + ] + }, + { + "title": "Preferences", + "slug": "preferences", + "components": [ + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Personal information", + "description": "Basic info about you, like your name and contact details, that you use across services.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:form", + "config": { + "form_id": "$FORM_PERSONAL_INFO", + "completion_message": "Your personal information has been updated." + } + } + ] + } + }, + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Communication preferences", + "description": "Choose which emails and notifications you'd like to receive from us.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:form", + "config": { + "form_id": "$FORM_COMMUNICATION_PREFERENCES", + "completion_message": "Your communication preferences have been updated." + } + } + ] + } + } + ] + } + ] +} diff --git a/internal/portals/data/templates/consumer.json b/internal/portals/data/templates/consumer.json new file mode 100644 index 000000000..932086893 --- /dev/null +++ b/internal/portals/data/templates/consumer.json @@ -0,0 +1,126 @@ +{ + "default_page": "profile", + "sidebar": [ + { + "type": "sidebar:component:auth0:internal_link:v2", + "config": { + "label": "Profile", + "page_slug": "profile", + "icon": "user" + } + }, + { + "type": "sidebar:component:auth0:internal_link:v2", + "config": { + "label": "Security", + "page_slug": "security", + "icon": "shield-check" + } + }, + { + "type": "sidebar:component:auth0:internal_link:v2", + "config": { + "label": "Legal & privacy", + "page_slug": "legal-privacy", + "icon": "file-text" + } + } + ], + "pages": [ + { + "title": "Profile", + "slug": "profile", + "components": [ + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Personal information", + "description": "Basic info about you, like your name and contact details, that you use across services.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:form", + "config": { + "form_id": "$FORM_PERSONAL_INFO", + "completion_message": "Your personal information has been updated." + } + } + ] + } + } + ] + }, + { + "title": "Security", + "slug": "security", + "components": [ + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Multi-factor authentication", + "description": "Add an extra layer of protection to your account by requiring a second verification step each time you sign in.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:my_account:mfa_management" + } + ] + } + }, + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Passkeys", + "description": "Use your fingerprint, face, or screen lock instead of a password to sign in quickly and more securely.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:my_account:passkey_management" + } + ] + } + } + ] + }, + { + "title": "Legal & privacy", + "slug": "legal-privacy", + "components": [ + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Privacy & data consent", + "description": "Control how your personal data is collected and used across our services.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:form", + "config": { + "form_id": "$FORM_PRIVACY_CONSENT", + "completion_message": "Your privacy preferences have been saved." + } + } + ] + } + }, + { + "type": "page:component:auth0:structure:section", + "config": { + "title": "Communication preferences", + "description": "Choose which emails and notifications you'd like to receive from us.", + "variant": "card", + "children": [ + { + "type": "page:component:auth0:form", + "config": { + "form_id": "$FORM_COMMUNICATION_PREFERENCES", + "completion_message": "Your communication preferences have been updated." + } + } + ] + } + } + ] + } + ] +} diff --git a/internal/portals/data_test.go b/internal/portals/data_test.go new file mode 100644 index 000000000..fd4744e1d --- /dev/null +++ b/internal/portals/data_test.go @@ -0,0 +1,268 @@ +package portals_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" +) + +var testFormIDs = portals.FormIDs{ + portals.FormPersonalInfo: "ap_personal", + portals.FormPrivacyConsent: "ap_privacy", + portals.FormCommunicationPreferences: "ap_comms", +} + +func TestForms(t *testing.T) { + t.Parallel() + + documents, err := portals.BuildFormDocuments("ac_test_vault") + require.NoError(t, err) + + keys := []portals.FormKey{portals.FormPersonalInfo, portals.FormCommunicationPreferences, portals.FormPrivacyConsent} + + t.Run("there are three forms, with the names they have in the tenant", func(t *testing.T) { + t.Parallel() + + assert.Len(t, documents, 3) + + for _, key := range keys { + form, _ := documents[key]["form"].(map[string]any) + assert.Equal(t, portals.FormName(key), form["name"], string(key)) + } + }) + + t.Run("every form routes its user update through the vault connection", func(t *testing.T) { + t.Parallel() + + for _, key := range keys { + action := firstAction(t, documents[key]) + + assert.Equal(t, "UPDATE_USER", action["action"], string(key)) + assert.Equal(t, "#CONN-1#", action["params"].(map[string]any)["connection_id"], string(key)) + assert.Equal(t, map[string]any{"#CONN-1#": map[string]any{"id": "ac_test_vault"}}, documents[key]["connections"], string(key)) + } + }) + + t.Run("each form writes its answers to the metadata it is meant to update", func(t *testing.T) { + t.Parallel() + + expected := map[portals.FormKey]string{ + portals.FormPersonalInfo: "user_metadata", + portals.FormCommunicationPreferences: "app_metadata", + portals.FormPrivacyConsent: "app_metadata", + } + + for _, key := range keys { + changes := firstAction(t, documents[key])["params"].(map[string]any)["changes"].(map[string]any) + + assert.Contains(t, changes, expected[key], string(key)) + } + }) + + t.Run("every form has a step that links to its flow", func(t *testing.T) { + t.Parallel() + + for _, key := range keys { + nodes := documents[key]["form"].(map[string]any)["nodes"].([]any) + + var stepNext, flowID string + + for _, node := range nodes { + entry := node.(map[string]any) + + switch entry["type"] { + case "STEP": + stepNext, _ = entry["config"].(map[string]any)["next_node"].(string) + case "FLOW": + flowID, _ = entry["id"].(string) + } + } + + assert.NotEmpty(t, flowID, "%s has no flow node", key) + assert.Equal(t, flowID, stepNext, "%s: the step does not lead to the flow", key) + } + }) + + t.Run("the vault ID is put in as a value, whatever characters it has", func(t *testing.T) { + t.Parallel() + + built, err := portals.BuildFormDocuments(`ac_"quoted"\id`) + require.NoError(t, err) + + assert.Equal(t, `ac_"quoted"\id`, built[portals.FormPersonalInfo]["connections"].(map[string]any)["#CONN-1#"].(map[string]any)["id"]) + }) +} + +func firstAction(t *testing.T, document map[string]any) map[string]any { + t.Helper() + + flows, ok := document["flows"].(map[string]any) + require.True(t, ok) + + flow, ok := flows["#FLOW-1#"].(map[string]any) + require.True(t, ok) + + actions, ok := flow["actions"].([]any) + require.True(t, ok) + + return actions[0].(map[string]any) +} + +// The limits the API puts on a portal. +const ( + maxTitleLength = 150 + maxSectionChildren = 20 + sectionComponent = "page:component:auth0:structure:section" + formComponent = "page:component:auth0:form" + internalLinkV2 = "sidebar:component:auth0:internal_link:v2" + organizationAPIPart = "my_organization" +) + +func TestTemplates(t *testing.T) { + t.Parallel() + + for _, kind := range []portals.Template{portals.TemplateConsumer} { + template, err := portals.TemplateFor(kind, testFormIDs) + require.NoError(t, err) + + t.Run(string(kind)+": every sidebar link points to an existing page", func(t *testing.T) { + t.Parallel() + + pages := pageSlugs(template) + for _, item := range template.Sidebar { + assert.Equal(t, internalLinkV2, item.Type) + assert.Contains(t, pages, item.Config["page_slug"], "link to a page that does not exist") + } + }) + + t.Run(string(kind)+": every page is in the sidebar", func(t *testing.T) { + t.Parallel() + + var linked []string + for _, item := range template.Sidebar { + linked = append(linked, item.Config["page_slug"].(string)) + } + + for _, page := range template.Pages { + assert.Contains(t, linked, page.Slug, "page not in the navigation") + } + }) + + t.Run(string(kind)+": the default page is one of the pages", func(t *testing.T) { + t.Parallel() + + assert.Contains(t, pageSlugs(template), template.DefaultPage) + }) + + t.Run(string(kind)+": every slug and icon matches what the API accepts", func(t *testing.T) { + t.Parallel() + + for _, page := range template.Pages { + assert.Regexp(t, `^[a-z][a-z0-9-]{0,49}$`, page.Slug) + } + + for _, item := range template.Sidebar { + assert.Regexp(t, `^[a-z0-9-]{1,50}$`, item.Config["icon"]) + } + }) + + t.Run(string(kind)+": every title fits the limits", func(t *testing.T) { + t.Parallel() + + for _, page := range template.Pages { + assert.NotEmpty(t, page.Title) + assert.LessOrEqual(t, len(page.Title), maxTitleLength) + } + }) + + t.Run(string(kind)+": every component is a section, and sections hold only leaf components", func(t *testing.T) { + t.Parallel() + + for _, page := range template.Pages { + for _, component := range page.Components { + assert.Equal(t, sectionComponent, component.Type, "a component outside a section") + + children, _ := component.Config["children"].([]any) + assert.LessOrEqual(t, len(children), maxSectionChildren) + + for _, child := range children { + assert.NotEqual(t, sectionComponent, child.(map[string]any)["type"], "sections cannot be nested") + } + } + } + }) + + t.Run(string(kind)+": every form used is one of the forms that were supplied", func(t *testing.T) { + t.Parallel() + + known := []string{"ap_personal", "ap_privacy", "ap_comms"} + for _, formID := range formIDsUsed(template.Pages) { + assert.Contains(t, known, formID) + } + }) + + t.Run(string(kind)+": no placeholder is left", func(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(template) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "$FORM_") + }) + } + + t.Run("consumer: uses only the My Account API and no organization components", func(t *testing.T) { + t.Parallel() + + template, err := portals.TemplateFor(portals.TemplateConsumer, testFormIDs) + require.NoError(t, err) + + encoded, err := json.Marshal(template) + require.NoError(t, err) + assert.False(t, strings.Contains(string(encoded), organizationAPIPart), "an organization component in the consumer template") + + assert.Equal(t, "profile", template.DefaultPage) + assert.Equal(t, []string{"profile", "security", "legal-privacy"}, pageSlugs(template)) + }) +} + +func pageSlugs(template portals.PortalTemplate) []string { + slugs := make([]string, 0, len(template.Pages)) + for _, page := range template.Pages { + slugs = append(slugs, page.Slug) + } + + return slugs +} + +// formIDsUsed returns the IDs of the forms the pages use. +func formIDsUsed(pages []portals.Page) []string { + var ids []string + + for _, page := range pages { + for _, component := range page.Components { + children, _ := component.Config["children"].([]any) + + for _, child := range children { + entry := child.(map[string]any) + if entry["type"] == formComponent { + ids = append(ids, entry["config"].(map[string]any)["form_id"].(string)) + } + } + } + } + + return ids +} + +func TestTemplateFor_UnknownTemplate(t *testing.T) { + t.Parallel() + + _, err := portals.TemplateFor("enterprise", testFormIDs) + + assert.EqualError(t, err, `unknown template "enterprise"`) +} diff --git a/internal/portals/errors.go b/internal/portals/errors.go new file mode 100644 index 000000000..226e23321 --- /dev/null +++ b/internal/portals/errors.go @@ -0,0 +1,251 @@ +package portals + +import ( + "errors" + "fmt" + "net/http" + "slices" + "strings" +) + +// APIError is a failed Management API request, whatever client made it. +type APIError struct { + // Status is the HTTP status code. + Status int + // Code is the errorCode the API returned, if any. + Code string + // Message is the message the API returned, if any. + Message string + // Err is the error of the client that made the request, kept so the CLI can classify the failure. + Err error +} + +// Unwrap returns the error of the client that made the request. +func (e *APIError) Unwrap() error { return e.Err } + +func (e *APIError) Error() string { + switch { + case e.Code != "" && e.Message != "": + return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) + case e.Message != "": + return fmt.Sprintf("%d: %s", e.Status, e.Message) + default: + return fmt.Sprintf("%d: %s", e.Status, http.StatusText(e.Status)) + } +} + +// StatusOf returns the HTTP status of an API failure in err's chain, or 0. +func StatusOf(err error) int { + if apiErr, ok := errors.AsType[*APIError](err); ok { + return apiErr.Status + } + + return 0 +} + +// CodeOf returns the errorCode of an API failure in err's chain, or "". +func CodeOf(err error) string { + if apiErr, ok := errors.AsType[*APIError](err); ok { + return apiErr.Code + } + + return "" +} + +// IsStatus reports whether err is an API failure with one of the given statuses. +func IsStatus(err error, statuses ...int) bool { + status := StatusOf(err) + if status == 0 { + return false + } + + for _, want := range statuses { + if status == want { + return true + } + } + + return false +} + +// FeatureNotEnabledError means Universal Portals is not enabled for the tenant. +type FeatureNotEnabledError struct { + Tenant string +} + +func (e *FeatureNotEnabledError) Error() string { + return fmt.Sprintf( + "Universal Portals is not enabled for %s. Ask Auth0 to enable it for this tenant, then run the command again", + e.Tenant, + ) +} + +// MissingScopesError means the session lacks permissions setup needs. Setup checks them before it +// creates anything. +type MissingScopesError struct { + // Scopes are the scopes that are all needed. + Scopes []string + // Choices are the checks where any one of several scopes would do. + Choices []ScopeGap +} + +func (e *MissingScopesError) needs() []string { + needs := slices.Clone(e.Scopes) + for _, choice := range e.Choices { + needs = append(needs, "one of "+strings.Join(choice.Scopes, " or ")) + } + + return needs +} + +func (e *MissingScopesError) Error() string { + needs := e.needs() + + return fmt.Sprintf( + "The CLI session is missing %d permission(s) setup needs: %s. Nothing was created", + len(needs), + strings.Join(needs, ", "), + ) +} + +// Fixes returns how to get the missing scopes. When any one of several scopes is enough, the +// first is the one to ask for. +func (e *MissingScopesError) Fixes() []string { + if len(e.needs()) == 0 { + return nil + } + + missing := slices.Clone(e.Scopes) + for _, choice := range e.Choices { + missing = append(missing, choice.Required()...) + } + + return ScopeFixes(missing) +} + +// PermissionCheckError means the Management API refused a check with a 403 and no reason. It +// usually means Universal Portals is not enabled, and setup stops before it creates anything. +type PermissionCheckError struct { + Probes []string + Tenant string +} + +func (e *PermissionCheckError) Error() string { + quoted := make([]string, 0, len(e.Probes)) + for _, probe := range e.Probes { + quoted = append(quoted, fmt.Sprintf("%q", probe)) + } + + return fmt.Sprintf( + "The Management API refused %s with HTTP 403 and gave no reason. Nothing was created. "+ + "Usually Universal Portals is not enabled for %s, or the session is missing a permission", + strings.Join(quoted, ", "), + e.Tenant, + ) +} + +// Fixes returns what to check. +func (e *PermissionCheckError) Fixes() []string { + return append([]string{"Check that Universal Portals is enabled for this tenant."}, ScopeFixes(nil)...) +} + +// fixable is an error that knows how to be fixed. +type fixable interface { + error + Fixes() []string +} + +// FixesOf returns the commands or steps that resolve an error: the fixes the error names, or, for a +// request the API refused for a missing scope, how to get the scope. Whichever command hit it, a +// missing scope is fixed the same way. +func FixesOf(err error) []string { + if fixer, ok := errors.AsType[fixable](err); ok { + return fixer.Fixes() + } + + if gap, isGap := ParseScopeGap("", err); isGap { + return ScopeFixes(gap.Required()) + } + + return nil +} + +// UsageError is a command that cannot run as asked, for example a slug the API would refuse. +type UsageError struct { + Message string + // Hints are the steps that resolve it. + Hints []string +} + +func (e *UsageError) Error() string { return e.Message } + +// Fixes returns the steps that resolve the error. +func (e *UsageError) Fixes() []string { return e.Hints } + +// NameInUseError means an application with the name exists and was not created by this setup, so it +// is never reused. +type NameInUseError struct { + Name string +} + +func (e *NameInUseError) Error() string { + return fmt.Sprintf( + "An application named %q already exists and was not created by this setup. Rename or delete it, then rerun", + e.Name, + ) +} + +// SlugTakenError means another portal uses the slug. +type SlugTakenError struct { + Slug string +} + +func (e *SlugTakenError) Error() string { + return fmt.Sprintf("The portal slug %q is already used by another portal", e.Slug) +} + +// Fixes returns how to resolve the error. +func (e *SlugTakenError) Fixes() []string { return []string{"Rerun with a different --slug"} } + +// SlugRaceError means another portal took the slug while setup was running. Setup checks the slug +// before it creates anything, so this is a race, and the rerun needs a new name and slug. +type SlugRaceError struct { + Slug string +} + +func (e *SlugRaceError) Error() string { + return fmt.Sprintf("The slug %q was taken while setup was running. Rerun with a different --name and --slug", e.Slug) +} + +// ClientSecretUnreadableError means the secret of an application could not be read, which takes the +// read:client_keys scope. +type ClientSecretUnreadableError struct { + ClientID string +} + +func (e *ClientSecretUnreadableError) Error() string { + return fmt.Sprintf("Could not read the client secret of application %q. The session needs read:client_keys", e.ClientID) +} + +// Fixes returns how to get the scope. +func (e *ClientSecretUnreadableError) Fixes() []string { + return ScopeFixes([]string{"read:client_keys"}) +} + +// asSlugTaken reports whether err is a *SlugTakenError. +func asSlugTaken(err error) (*SlugTakenError, bool) { + return errors.AsType[*SlugTakenError](err) +} + +// BusinessAccessError means the user who will test a business portal cannot be used, or has no +// access to an organization. Setup checks it before it creates anything. +type BusinessAccessError struct { + Message string + // Hints are the steps that resolve it. + Hints []string +} + +func (e *BusinessAccessError) Error() string { return e.Message } + +// Fixes returns the steps that resolve the error. +func (e *BusinessAccessError) Fixes() []string { return e.Hints } diff --git a/internal/portals/errors_test.go b/internal/portals/errors_test.go new file mode 100644 index 000000000..580016f63 --- /dev/null +++ b/internal/portals/errors_test.go @@ -0,0 +1,131 @@ +package portals_test + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/portals" +) + +func TestAPIError_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err *portals.APIError + want string + }{ + { + name: "with a code and a message", + err: &portals.APIError{Status: 409, Code: "portal_conflict", Message: "Slug already in use"}, + want: "409 portal_conflict: Slug already in use", + }, + { + name: "with a message only", + err: &portals.APIError{Status: 404, Message: "The client does not exist"}, + want: "404: The client does not exist", + }, + { + name: "with a status only", + err: &portals.APIError{Status: 500}, + want: "500: Internal Server Error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, tt.err.Error()) + }) + } +} + +func TestStatusCodeAndIsStatus(t *testing.T) { + t.Parallel() + + wrapped := fmt.Errorf("create client %q: %w", "Acme", &portals.APIError{Status: http.StatusConflict, Code: "conflict"}) + + tests := []struct { + name string + err error + wantStatus int + wantCode string + isConflict bool + }{ + {name: "an API error", err: &portals.APIError{Status: 409, Code: "conflict"}, wantStatus: 409, wantCode: "conflict", isConflict: true}, + {name: "an API error with context added", err: wrapped, wantStatus: 409, wantCode: "conflict", isConflict: true}, + {name: "another error", err: errors.New("network down")}, + {name: "no error"}, + {name: "another status", err: &portals.APIError{Status: 404}, wantStatus: 404}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.wantStatus, portals.StatusOf(tt.err)) + assert.Equal(t, tt.wantCode, portals.CodeOf(tt.err)) + assert.Equal(t, tt.isConflict, portals.IsStatus(tt.err, http.StatusConflict, http.StatusBadRequest)) + }) + } +} + +func TestFixesOf(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want []string + }{ + { + name: "an error that names its fixes", + err: &portals.MissingScopesError{Scopes: []string{"read:clients"}}, + want: portals.ScopeFixes([]string{"read:clients"}), + }, + { + name: "an error that names its fixes, with context added", + err: fmt.Errorf("set up: %w", &portals.MissingScopesError{Scopes: []string{"read:clients"}}), + want: portals.ScopeFixes([]string{"read:clients"}), + }, + { + name: "a missing scope with nothing to ask for has no fixes", + err: &portals.MissingScopesError{}, + }, + { + name: "a request refused for one of several scopes: the first is enough", + err: &portals.APIError{Status: 403, Code: "insufficient_scope", Message: "Insufficient scope, expected any of: read:portals,update:portals"}, + want: portals.ScopeFixes([]string{"read:portals"}), + }, + { + name: "a request refused for all of several scopes", + err: fmt.Errorf("read portal: %w", &portals.APIError{Status: 403, Message: "Insufficient scope, expected all of: read:portals,update:portals"}), + want: portals.ScopeFixes([]string{"read:portals", "update:portals"}), + }, + {name: "a 403 that is not about a scope", err: &portals.APIError{Status: 403, Code: "forbidden", Message: "Forbidden"}}, + {name: "another error", err: errors.New("network down")}, + {name: "no error"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, portals.FixesOf(tt.err)) + }) + } +} + +func TestPermissionCheckError_Fixes(t *testing.T) { + t.Parallel() + + fixes := (&portals.PermissionCheckError{Probes: []string{"create portal"}, Tenant: "acme.us.auth0.com"}).Fixes() + + assert.Equal(t, "Check that Universal Portals is enabled for this tenant.", fixes[0]) + assert.Len(t, fixes, 3) +} diff --git a/internal/portals/export_test.go b/internal/portals/export_test.go new file mode 100644 index 000000000..d05fec1a6 --- /dev/null +++ b/internal/portals/export_test.go @@ -0,0 +1,228 @@ +package portals + +import "context" + +// The helpers below let the tests in portals_test call the steps of setup one by one. They are +// compiled only for tests. + +// EnsuredID is the outcome of a step that creates or finds one resource. +type EnsuredID struct { + ID string + Status Status +} + +// EnsuredCredentials is the outcome of a step that creates or finds an application. +type EnsuredCredentials struct { + ClientID string + ClientSecret string + Status Status +} + +// PortalApplicationInput describes the application of a portal. +type PortalApplicationInput struct { + Name string + Slug string + Template Template + TenantDomain string + Host string + IsCustomDomain bool +} + +func EnsureResourceServer(ctx context.Context, store ResourceServerStore, spec ResourceServerSpec) (EnsuredID, error) { + outcome, err := ensureResourceServer(ctx, store, spec) + + return EnsuredID{ID: outcome.value, Status: outcome.status}, err +} + +func EnsurePortalClient(ctx context.Context, store ClientStore, input PortalApplicationInput) (EnsuredCredentials, error) { + outcome, err := ensurePortalClient(ctx, store, portalApplication{ + name: input.Name, + slug: input.Slug, + template: input.Template, + tenantDomain: input.TenantDomain, + host: input.Host, + isCustomDomain: input.IsCustomDomain, + }) + + return EnsuredCredentials{ + ClientID: outcome.value.clientID, + ClientSecret: outcome.value.clientSecret, + Status: outcome.status, + }, err +} + +func PortalClientSettings(input PortalApplicationInput) ClientSettings { + return portalClientSettings(portalApplication{ + name: input.Name, + slug: input.Slug, + template: input.Template, + tenantDomain: input.TenantDomain, + host: input.Host, + isCustomDomain: input.IsCustomDomain, + }) +} + +func EnsureClientGrant(ctx context.Context, store ClientGrantStore, spec ClientGrantSpec) (EnsuredID, error) { + outcome, err := ensureClientGrant(ctx, store, spec) + + return EnsuredID{ID: outcome.value, Status: outcome.status}, err +} + +func EnsureVaultConnection(ctx context.Context, store VaultStore, name string, setup VaultSetup) (EnsuredID, error) { + outcome, err := ensureVaultConnection(ctx, store, name, setup) + + return EnsuredID{ID: outcome.value, Status: outcome.status}, err +} + +func RefreshVaultConnection(ctx context.Context, store VaultStore, id string, setup VaultSetup) error { + return refreshVaultConnection(ctx, store, id, setup) +} + +// EnsuredForm is the outcome of a step that creates or finds one form. +type EnsuredForm struct { + Key FormKey + ID string + FlowID string + Status Status +} + +func EnsureForms(ctx context.Context, store FormStore, vaultConnectionID string) ([]EnsuredForm, error) { + definitions, err := buildForms(vaultConnectionID) + if err != nil { + return nil, err + } + + outcomes, err := ensureForms(ctx, store, definitions) + + forms := make([]EnsuredForm, 0, len(outcomes)) + for _, outcome := range outcomes { + forms = append(forms, EnsuredForm{Key: outcome.key, ID: outcome.ref.id, FlowID: outcome.ref.flowID, Status: outcome.status}) + } + + return forms, err +} + +func BuildFormDocuments(vaultConnectionID string) (map[FormKey]map[string]any, error) { + definitions, err := buildForms(vaultConnectionID) + if err != nil { + return nil, err + } + + documents := make(map[FormKey]map[string]any, len(definitions)) + for _, definition := range definitions { + documents[definition.key] = definition.document + } + + return documents, nil +} + +// FormIDs are the IDs of the forms a template uses. +type FormIDs = formIDs + +// PortalTemplate is the pages of a kind of portal. +type PortalTemplate = portalTemplate + +func TemplateFor(template Template, forms FormIDs) (PortalTemplate, error) { + return templateFor(template, forms) +} + +// EnsurePortal creates the portal of a template for the application, or finds it. +func EnsurePortal( + ctx context.Context, + store PortalStore, + template PortalTemplate, + slug, name, clientID, clientSecret string, +) (EnsuredID, error) { + outcome, err := ensurePortal(ctx, store, portalRequest(template, slug, name, credentials{clientID: clientID, clientSecret: clientSecret})) + + return EnsuredID{ID: outcome.value.ID, Status: outcome.status}, err +} + +func FormsVaultName() string { return formsVaultName } + +func PortalURL(host, slug, organizationID string) string { + return portalURL(host, slug, organizationID) +} + +func AudiencesFor(template Template, tenantDomain string) map[string][]string { + audiences := map[string][]string{} + for _, access := range audiencesFor(template, tenantDomain) { + audiences[access.audience] = access.scope + } + + return audiences +} + +// The helpers below let the tests build a snapshot of a portal and look at what the audit and its +// fixes do. + +// AuditSnapshot is everything the audit reads about a portal. +type AuditSnapshot = snapshot + +// FormStatus is a form of a portal, and whether it exists. +type FormStatus = formStatus + +// SnapshotOrganization is the organization a portal opens in. +type SnapshotOrganization = snapshotOrganization + +// AuditFindings checks a snapshot. +func AuditFindings(current AuditSnapshot) []Finding { return auditPortal(current) } + +// CanFix reports whether the CLI can fix the finding. +func (f Finding) CanFix() bool { return f.canFix() } + +// Key identifies the finding. +func (f Finding) Key() string { return f.key() } + +// FixableFindings returns the findings the CLI fixes. +func FixableFindings(findings []Finding) []Finding { return fixableFindings(findings) } + +// FixSummary is what a pass of fixes did. +type FixSummary struct { + Applied int + Failed int + AlreadyFixed int + MissingScopes []string +} + +// FindingsOf reads a portal and audits it. +func FindingsOf(ctx context.Context, api API, tenantDomain, portalID string) ([]Finding, error) { + current, err := readSnapshot(ctx, api, portalID, tenantDomain, "") + if err != nil { + return nil, err + } + + return auditPortal(current), nil +} + +func fixDepsFor(api API, reporter Reporter, tenantDomain, portalID string) fixDeps { + return fixDeps{ + api: api, + reporter: reporter, + read: func(ctx context.Context) (snapshot, error) { + return readSnapshot(ctx, api, portalID, tenantDomain, "") + }, + } +} + +// FixFindings applies the fixes of the findings. +func FixFindings(ctx context.Context, api API, reporter Reporter, tenantDomain, portalID string, selected []Finding) (FixSummary, error) { + result, err := applyFixes(ctx, selected, fixDepsFor(api, reporter, tenantDomain, portalID)) + + return FixSummary{Applied: result.applied, Failed: result.failed, AlreadyFixed: result.alreadyFixed, MissingScopes: result.missingScopes}, err +} + +// FixFindingRounds applies the fixes of the findings, and then those of the errors they exposed that +// chooseNext picks. +func FixFindingRounds( + ctx context.Context, + api API, + reporter Reporter, + tenantDomain, portalID string, + selected []Finding, + chooseNext func(exposed []Finding) ([]Finding, error), +) (FixSummary, error) { + result, err := applyFixRounds(ctx, selected, fixDepsFor(api, reporter, tenantDomain, portalID), chooseNext) + + return FixSummary{Applied: result.applied, Failed: result.failed, AlreadyFixed: result.alreadyFixed, MissingScopes: result.missingScopes}, err +} diff --git a/internal/portals/helpers_test.go b/internal/portals/helpers_test.go new file mode 100644 index 000000000..0ec69626e --- /dev/null +++ b/internal/portals/helpers_test.go @@ -0,0 +1,23 @@ +package portals_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +// wrapped adds context to an error, as the code around a store does. +func wrapped(err error) error { + return fmt.Errorf("check the permissions: %w", err) +} + +func mustJSON(t *testing.T, value any) string { + t.Helper() + + data, err := json.Marshal(value) + require.NoError(t, err) + + return string(data) +} diff --git a/internal/portals/parallel.go b/internal/portals/parallel.go new file mode 100644 index 000000000..81612cb3b --- /dev/null +++ b/internal/portals/parallel.go @@ -0,0 +1,30 @@ +package portals + +import "sync" + +// maxParallel is the number of requests sent at once. The Management API limits the rate of +// requests, and a handful at a time is fast enough. +const maxParallel = 4 + +// forEachParallel calls fn for every index below count, with at most maxParallel calls running at +// once, and returns when all of them are done. Each call writes only to its own index of the results. +func forEachParallel(count int, fn func(index int)) { + var group sync.WaitGroup + + slots := make(chan struct{}, maxParallel) + + for index := range count { + slots <- struct{}{} + + group.Add(1) + + go func() { + defer group.Done() + defer func() { <-slots }() + + fn(index) + }() + } + + group.Wait() +} diff --git a/internal/portals/parallel_test.go b/internal/portals/parallel_test.go new file mode 100644 index 000000000..cb411fcce --- /dev/null +++ b/internal/portals/parallel_test.go @@ -0,0 +1,85 @@ +package portals + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestForEachParallel(t *testing.T) { + t.Parallel() + + t.Run("it calls the function once for every index", func(t *testing.T) { + t.Parallel() + + var calls [20]atomic.Int32 + + forEachParallel(len(calls), func(index int) { calls[index].Add(1) }) + + for index := range calls { + assert.Equal(t, int32(1), calls[index].Load(), "index %d", index) + } + }) + + t.Run("it runs several calls at once, never more than the limit", func(t *testing.T) { + t.Parallel() + + var ( + running atomic.Int32 + peak atomic.Int32 + release = make(chan struct{}) + started sync.WaitGroup + ) + + started.Add(maxParallel) + + go func() { + // The first calls wait here until the limit is reached, which proves they run together. If + // they never reach it the test fails, and the calls are let go so it does not hang. + reached := make(chan struct{}) + + go func() { + started.Wait() + close(reached) + }() + + select { + case <-reached: + case <-time.After(5 * time.Second): + t.Error("the calls did not run at once") + } + + close(release) + }() + + forEachParallel(maxParallel*3, func(int) { + now := running.Add(1) + defer running.Add(-1) + + for { + seen := peak.Load() + if now <= seen || peak.CompareAndSwap(seen, now) { + break + } + } + + select { + case <-release: + default: + started.Done() + <-release + } + }) + + assert.Equal(t, int32(maxParallel), peak.Load()) + }) + + t.Run("it does nothing for no indices", func(t *testing.T) { + t.Parallel() + + forEachParallel(0, func(int) { t.Error("the function must not be called") }) + }) +} diff --git a/internal/portals/payload.go b/internal/portals/payload.go new file mode 100644 index 000000000..1e07e68e4 --- /dev/null +++ b/internal/portals/payload.go @@ -0,0 +1,187 @@ +package portals + +import "strings" + +// The scopes the portal application needs on each API, as listed in the setup guide. +var ( + myAccountScopes = []string{ + "read:me:factors", + "read:me:authentication_methods", + "create:me:authentication_methods", + "delete:me:authentication_methods", + } + + // The scopes the organization components of the business template use: the details of the + // organization (read and update), and its verified domains and identity provider domains. + myOrganizationScopes = []string{ + "read:my_org:details", + "update:my_org:details", + "read:my_org:configuration", + "read:my_org:domains", + "create:my_org:domains", + "update:my_org:domains", + "delete:my_org:domains", + "read:my_org:identity_providers", + "create:my_org:identity_providers_domains", + "delete:my_org:identity_providers_domains", + } + + // The scopes of the machine-to-machine application behind the vault connection of the forms. + formsVaultScopes = []string{"update:users", "update:users_app_metadata"} + + portalGrantTypes = []string{ + "authorization_code", + "refresh_token", + "http://auth0.com/oauth/grant-type/mfa-oob", + "http://auth0.com/oauth/grant-type/mfa-otp", + "http://auth0.com/oauth/grant-type/mfa-recovery-code", + } + + backchannelLogoutInitiators = []string{ + "idp-logout", + "rp-logout", + "session-expired", + "session-revoked", + "account-deleted", + "account-deactivated", + } +) + +const ( + // The suffix of the name of the portal application, after the name of the portal. + portalApplicationSuffix = "(Universal Portals)" + // The name of the application and of the vault connection that serve the forms of every portal. + formsVaultName = "Universal Portals forms vault" + // The metadata that tells which setup created an application. + portalSlugMetadata = "portals_setup_slug" + formsVaultMetadata = "auth0_portals_role" + formsVaultRole = "forms-vault" + + refreshTokenIdleSeconds = 3600 + refreshTokenAbsoluteSeconds = 86400 +) + +// MyAccountScopes returns the scopes the portal application needs on the My Account API. +func MyAccountScopes() []string { return append([]string(nil), myAccountScopes...) } + +// MyOrganizationScopes returns the scopes the portal application needs on the My Organization API. +func MyOrganizationScopes() []string { return append([]string(nil), myOrganizationScopes...) } + +// FormsVaultScopes returns the scopes of the application behind the vault connection. +func FormsVaultScopes() []string { return append([]string(nil), formsVaultScopes...) } + +func myAccountAudience(tenantDomain string) string { return "https://" + tenantDomain + "/me/" } + +func myOrganizationAudience(tenantDomain string) string { + return "https://" + tenantDomain + "/my-org/" +} + +func managementAudience(tenantDomain string) string { return "https://" + tenantDomain + "/api/v2/" } + +// apiAccess is an API the portal application calls, with the scopes it needs there. +type apiAccess struct { + audience string + scope []string +} + +// audiencesFor returns the APIs a template calls. Setup creates a client grant and a refresh token +// policy for each. +func audiencesFor(template Template, tenantDomain string) []apiAccess { + account := apiAccess{audience: myAccountAudience(tenantDomain), scope: myAccountScopes} + if template == TemplateConsumer { + return []apiAccess{account} + } + + return []apiAccess{{audience: myOrganizationAudience(tenantDomain), scope: myOrganizationScopes}, account} +} + +// apiLabel is the name of an API in what setup shows. +func apiLabel(audience, tenantDomain string) string { + switch audience { + case myAccountAudience(tenantDomain): + return "My Account API" + case myOrganizationAudience(tenantDomain): + return "My Organization API" + default: + return "Management API" + } +} + +// portalApplication is what the settings of the portal application are built from. +type portalApplication struct { + name string + slug string + template Template + tenantDomain string + // The host the portal is served from: the custom domain, or the tenant domain. + host string + isCustomDomain bool +} + +func boolPointer(value bool) *bool { return &value } + +func intPointer(value int) *int { return &value } + +// portalClientSettings are the settings of the application a portal signs users in with. +func portalClientSettings(app portalApplication) ClientSettings { + settings := ClientSettings{ + Name: app.name, + AppType: "regular_web", + IsFirstParty: boolPointer(true), + OIDCConformant: boolPointer(true), + TokenEndpointAuthMethod: "client_secret_post", + IPHeaderTrusted: boolPointer(true), + Callbacks: []string{"https://" + app.host + "/portals/auth/callback"}, + // Users land on the host of the portal after they log out. + AllowedLogoutURLs: []string{"https://" + app.host}, + GrantTypes: append([]string(nil), portalGrantTypes...), + OrganizationUsage: "allow", + OrganizationRequireBehavior: "no_prompt", + RefreshToken: refreshTokenSettings(app.template, app.tenantDomain), + Metadata: map[string]string{portalSlugMetadata: app.slug}, + // Lets a native application open the portal with a session transfer token. + SessionTransfer: &SessionTransfer{AllowedAuthenticationMethods: []string{"query"}, EnforceDeviceBinding: "ip"}, + } + + if app.isCustomDomain { + settings.OIDCLogout = &OIDCLogout{ + BackchannelLogoutURLs: []string{"https://" + app.host + "/portals/auth/backchannel-logout"}, + Mode: "custom", + SelectedInitiators: append([]string(nil), backchannelLogoutInitiators...), + } + } + + return settings +} + +func refreshTokenSettings(template Template, tenantDomain string) *RefreshToken { + token := &RefreshToken{ + RotationType: "non-rotating", + ExpirationType: "expiring", + TokenLifetime: intPointer(refreshTokenAbsoluteSeconds), + IdleTokenLifetime: intPointer(refreshTokenIdleSeconds), + } + + for _, access := range audiencesFor(template, tenantDomain) { + token.Policies = append(token.Policies, RefreshTokenPolicy{ + Audience: access.audience, + Scope: append([]string(nil), access.scope...), + }) + } + + return token +} + +// formsVaultClientSettings are the settings of the application behind the vault connection. +func formsVaultClientSettings(name string) ClientSettings { + return ClientSettings{ + Name: name, + AppType: "non_interactive", + Metadata: map[string]string{formsVaultMetadata: formsVaultRole}, + } +} + +// portalName is the name of the application of a portal. +func portalName(name string) string { + return strings.TrimSpace(name) + " " + portalApplicationSuffix +} diff --git a/internal/portals/portal.go b/internal/portals/portal.go new file mode 100644 index 000000000..914a4e384 --- /dev/null +++ b/internal/portals/portal.go @@ -0,0 +1,192 @@ +package portals + +import ( + "encoding/json" + "fmt" + "maps" + "slices" +) + +// PortalSummary is a portal in the list of the tenant. +type PortalSummary struct { + ID string + Slug string + Name string +} + +// Portal is a portal as the Management API returns it. +type Portal struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name,omitempty"` + Client *PortalClient `json:"client,omitempty"` + Navigation *Navigation `json:"navigation,omitempty"` + Pages *Pages `json:"pages,omitempty"` + CustomCode *CustomCode `json:"custom_code,omitempty"` +} + +// PortalClient is the application a portal signs users in with. +type PortalClient struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` +} + +// Navigation is the navigation of a portal. +type Navigation struct { + Sidebar *Sidebar `json:"sidebar,omitempty"` +} + +// Sidebar is the sidebar of a portal. +type Sidebar struct { + Components []SidebarComponent `json:"components,omitempty"` +} + +// SidebarComponent is a link in the sidebar. The config depends on the type. +type SidebarComponent struct { + Type string `json:"type"` + Config map[string]any `json:"config,omitempty"` +} + +// Pages are the pages of a portal. +type Pages struct { + // Default is the slug of the home page. + Default string `json:"default,omitempty"` + Standard []Page `json:"standard,omitempty"` +} + +// Page is a page of a portal. A fix sends the pages back whole, so the fields the package does not +// know are kept and sent as they were. +type Page struct { + Title string + Slug string + Components []Component + + extra map[string]json.RawMessage +} + +// Component is a block of a page. The config depends on the type, and a section holds its +// blocks in config.children. The fields the package does not know are kept. +type Component struct { + Type string + Config map[string]any + + extra map[string]json.RawMessage +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *Page) UnmarshalJSON(data []byte) error { + known, extra, err := splitFields(data, "title", "slug", "components") + if err != nil { + return err + } + + *p = Page{extra: extra} + + return decodeFields(known, map[string]any{"title": &p.Title, "slug": &p.Slug, "components": &p.Components}) +} + +// MarshalJSON implements json.Marshaler. +func (p Page) MarshalJSON() ([]byte, error) { + fields := map[string]any{"title": p.Title, "slug": p.Slug} + if p.Components != nil { + fields["components"] = p.Components + } + + return joinFields(fields, p.extra) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (c *Component) UnmarshalJSON(data []byte) error { + known, extra, err := splitFields(data, "type", "config") + if err != nil { + return err + } + + *c = Component{extra: extra} + + return decodeFields(known, map[string]any{"type": &c.Type, "config": &c.Config}) +} + +// MarshalJSON implements json.Marshaler. +func (c Component) MarshalJSON() ([]byte, error) { + fields := map[string]any{"type": c.Type} + if c.Config != nil { + fields["config"] = c.Config + } + + return joinFields(fields, c.extra) +} + +// splitFields separates the fields of an object that the package knows from the others. +func splitFields(data []byte, names ...string) (known, extra map[string]json.RawMessage, err error) { + var all map[string]json.RawMessage + if err := json.Unmarshal(data, &all); err != nil { + return nil, nil, err + } + + known = map[string]json.RawMessage{} + + for _, name := range names { + if value, found := all[name]; found { + known[name] = value + + delete(all, name) + } + } + + if len(all) == 0 { + all = nil + } + + return known, all, nil +} + +func decodeFields(fields map[string]json.RawMessage, targets map[string]any) error { + // The fields are read in the order of their names, so the error is always the same one. + for _, name := range slices.Sorted(maps.Keys(targets)) { + if value, found := fields[name]; found { + if err := json.Unmarshal(value, targets[name]); err != nil { + return fmt.Errorf("read the %q of the portal: %w", name, err) + } + } + } + + return nil +} + +// joinFields encodes the fields of the package together with the ones it kept. +func joinFields(fields map[string]any, extra map[string]json.RawMessage) ([]byte, error) { + merged := make(map[string]any, len(fields)+len(extra)) + + for name, value := range extra { + merged[name] = value + } + + for name, value := range fields { + merged[name] = value + } + + return json.Marshal(merged) +} + +// CustomCode is the custom code of a portal. +type CustomCode struct { + Head string `json:"head,omitempty"` +} + +// CreatePortalRequest describes a portal to create. +type CreatePortalRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + Client *PortalClient `json:"client"` + Navigation *Navigation `json:"navigation,omitempty"` + Pages *Pages `json:"pages,omitempty"` +} + +// PortalPatch changes the parts of a portal that are set, and keeps the others. +type PortalPatch struct { + Client *PortalClient `json:"client,omitempty"` + Navigation *Navigation `json:"navigation,omitempty"` + Pages *Pages `json:"pages,omitempty"` +} diff --git a/internal/portals/portal_test.go b/internal/portals/portal_test.go new file mode 100644 index 000000000..a45743fe6 --- /dev/null +++ b/internal/portals/portal_test.go @@ -0,0 +1,108 @@ +package portals_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" +) + +func TestPortal_APageSentBackIsNotChanged(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + json string + }{ + { + name: "a field the package does not know is kept", + json: `{"title":"Profile","slug":"profile","description":"Your profile","components":[]}`, + }, + { + name: "an empty list of components stays an empty list", + json: `{"title":"Billing","slug":"billing","components":[]}`, + }, + { + name: "a page with no list of components has none", + json: `{"title":"Billing","slug":"billing"}`, + }, + { + name: "the fields of a component the package does not know are kept", + json: `{"title":"P","slug":"p","components":[{"type":"page:component:auth0:form","config":{"form_id":"ap_1"},"visible":false}]}`, + }, + { + name: "a component with an empty config keeps it", + json: `{"title":"P","slug":"p","components":[{"type":"page:component:auth0:my_account:mfa_management","config":{}}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var page portals.Page + require.NoError(t, json.Unmarshal([]byte(tt.json), &page)) + + sent, err := json.Marshal(page) + + require.NoError(t, err) + assert.JSONEq(t, tt.json, string(sent)) + }) + } +} + +func TestPortal_APageIsReadForItsFields(t *testing.T) { + t.Parallel() + + var page portals.Page + require.NoError(t, json.Unmarshal([]byte(`{"title":"Profile","slug":"profile","extra":1,"components":[{"type":"a","config":{"children":[]}}]}`), &page)) + + assert.Equal(t, "Profile", page.Title) + assert.Equal(t, "profile", page.Slug) + require.Len(t, page.Components, 1) + assert.Equal(t, "a", page.Components[0].Type) + assert.Equal(t, map[string]any{"children": []any{}}, page.Components[0].Config) +} + +func TestPortal_APortalKeepsItsPagesWhenReadAndSent(t *testing.T) { + t.Parallel() + + const source = `{"id":"up_1","slug":"acme","pages":{"default":"profile","standard":[{"title":"Profile","slug":"profile","hidden":true}]}}` + + var portal portals.Portal + require.NoError(t, json.Unmarshal([]byte(source), &portal)) + + sent, err := json.Marshal(portals.PortalPatch{Pages: portal.Pages}) + + require.NoError(t, err) + assert.JSONEq(t, `{"pages":{"default":"profile","standard":[{"title":"Profile","slug":"profile","hidden":true}]}}`, string(sent)) +} + +func TestPortal_APageThatCannotBeReadSaysWhichField(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + json string + field string + }{ + {name: "a title that is not text", json: `{"title":5,"slug":"profile"}`, field: "title"}, + {name: "components that are not a list", json: `{"title":"Profile","slug":"profile","components":"none"}`, field: "components"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var page portals.Page + + err := json.Unmarshal([]byte(tt.json), &page) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.field) + }) + } +} diff --git a/internal/portals/portals.go b/internal/portals/portals.go new file mode 100644 index 000000000..77493de33 --- /dev/null +++ b/internal/portals/portals.go @@ -0,0 +1,5 @@ +// Package portals sets up and audits Universal Portals in an Auth0 tenant. +// +// The package holds the rules and the order of the work. It never talks to the Management API +// itself: the tenant is reached through the stores in store.go, which the CLI implements. +package portals diff --git a/internal/portals/portalstest/seed.go b/internal/portals/portalstest/seed.go new file mode 100644 index 000000000..a9123d066 --- /dev/null +++ b/internal/portals/portalstest/seed.go @@ -0,0 +1,264 @@ +package portalstest + +import ( + "slices" + + "github.com/auth0/auth0-cli/internal/portals" +) + +// AddUser adds a user to the tenant. +func (t *Tenant) AddUser(user portals.User) { + t.mu.Lock() + defer t.mu.Unlock() + + t.users = append(t.users, user) +} + +// AddOrganization adds an organization with the given members. +func (t *Tenant) AddOrganization(organization portals.Organization, memberIDs ...string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.organizations = append(t.organizations, organization) + t.members[organization.ID] = slices.Clone(memberIDs) +} + +// EnableOrganizationConnectionFor enables connections for an organization, without a request. +func (t *Tenant) EnableOrganizationConnectionFor(organizationID string, connectionIDs ...string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.orgConnections[organizationID] = append(t.orgConnections[organizationID], connectionIDs...) +} + +// AddConnection adds a connection with the given strategy, for example "auth0" for a database. +func (t *Tenant) AddConnection(connection portals.Connection, strategy string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.connections = append(t.connections, connectionRecord{Connection: connection, strategy: strategy}) +} + +// EnableClientConnection enables a connection for an application, without a request. +func (t *Tenant) EnableClientConnection(clientID, connectionID string) { + t.mu.Lock() + defer t.mu.Unlock() + + if index := slices.IndexFunc(t.clients, func(record clientRecord) bool { return record.ID == clientID }); index >= 0 { + t.clients[index].enabledConnections = append(t.clients[index].enabledConnections, connectionID) + } +} + +// AddRole adds a role with the given permissions. +func (t *Tenant) AddRole(role portals.Role, permissions ...portals.Permission) { + t.mu.Lock() + defer t.mu.Unlock() + + t.roles = append(t.roles, roleRecord{Role: role, permissions: slices.Clone(permissions)}) +} + +// AssignRole gives a member of an organization a role, without a request. +func (t *Tenant) AssignRole(organizationID, userID, roleID string) { + t.mu.Lock() + defer t.mu.Unlock() + + key := organizationID + ":" + userID + t.orgRoles[key] = append(t.orgRoles[key], roleID) +} + +// AddResourceServer adds a resource server, without a request. +func (t *Tenant) AddResourceServer(server portals.ResourceServer) { + t.mu.Lock() + defer t.mu.Unlock() + + t.resourceServers = append(t.resourceServers, server) +} + +// AddClient adds an application, without a request. +func (t *Tenant) AddClient(client portals.Client) { + t.mu.Lock() + defer t.mu.Unlock() + + t.clients = append(t.clients, clientRecord{Client: clone(client)}) +} + +// AddGrant adds a client grant, without a request. +func (t *Tenant) AddGrant(grant portals.ClientGrant) { + t.mu.Lock() + defer t.mu.Unlock() + + t.grants = append(t.grants, clone(grant)) +} + +// AddPortal adds a portal, without a request. +func (t *Tenant) AddPortal(portal portals.Portal) { + t.mu.Lock() + defer t.mu.Unlock() + + t.portals = append(t.portals, clone(portal)) +} + +// AddForm adds a form that runs the flow, without a request. +func (t *Tenant) AddForm(form portals.Form, flowID string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.forms = append(t.forms, formRecord{Form: form, flowID: flowID}) +} + +// DeleteForm removes a form, without a request. +func (t *Tenant) DeleteForm(id string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.forms = slices.DeleteFunc(t.forms, func(form formRecord) bool { return form.ID == id }) +} + +// DeleteClient removes an application, without a request. +func (t *Tenant) DeleteClient(id string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.clients = slices.DeleteFunc(t.clients, func(record clientRecord) bool { return record.ID == id }) +} + +// Clients returns every application with its secret. +func (t *Tenant) Clients() []portals.Client { + t.mu.Lock() + defer t.mu.Unlock() + + clients := make([]portals.Client, 0, len(t.clients)) + for _, record := range t.clients { + clients = append(clients, clone(record.Client)) + } + + return clients +} + +// Grants returns every client grant. +func (t *Tenant) Grants() []portals.ClientGrant { + t.mu.Lock() + defer t.mu.Unlock() + + return clone(t.grants) +} + +// Portals returns every portal. +func (t *Tenant) Portals() []portals.Portal { + t.mu.Lock() + defer t.mu.Unlock() + + return clone(t.portals) +} + +// ResourceServers returns every resource server. +func (t *Tenant) ResourceServers() []portals.ResourceServer { + t.mu.Lock() + defer t.mu.Unlock() + + return clone(t.resourceServers) +} + +// Forms returns every form. +func (t *Tenant) Forms() []portals.Form { + t.mu.Lock() + defer t.mu.Unlock() + + forms := make([]portals.Form, 0, len(t.forms)) + for _, record := range t.forms { + forms = append(forms, record.Form) + } + + return forms +} + +// VaultConnections returns every vault connection. +func (t *Tenant) VaultConnections() []portals.VaultConnection { + t.mu.Lock() + defer t.mu.Unlock() + + vaults := make([]portals.VaultConnection, 0, len(t.vaults)) + for _, record := range t.vaults { + vaults = append(vaults, record.VaultConnection) + } + + return vaults +} + +// Members returns the IDs of the members of an organization. +func (t *Tenant) Members(organizationID string) []string { + t.mu.Lock() + defer t.mu.Unlock() + + return slices.Clone(t.members[organizationID]) +} + +// Organizations returns every organization. +func (t *Tenant) Organizations() []portals.Organization { + t.mu.Lock() + defer t.mu.Unlock() + + return slices.Clone(t.organizations) +} + +// Roles returns every role. +func (t *Tenant) Roles() []portals.Role { + t.mu.Lock() + defer t.mu.Unlock() + + roles := make([]portals.Role, 0, len(t.roles)) + for _, record := range t.roles { + roles = append(roles, record.Role) + } + + return roles +} + +// RolePermissions returns the permissions of a role. +func (t *Tenant) RolePermissions(roleID string) []portals.Permission { + t.mu.Lock() + defer t.mu.Unlock() + + if index := slices.IndexFunc(t.roles, func(role roleRecord) bool { return role.ID == roleID }); index >= 0 { + return slices.Clone(t.roles[index].permissions) + } + + return nil +} + +// ModifyPortal changes a portal without a request, for a test that needs a portal that broke. +func (t *Tenant) ModifyPortal(id string, change func(portal *portals.Portal)) { + t.mu.Lock() + defer t.mu.Unlock() + + if index := slices.IndexFunc(t.portals, func(portal portals.Portal) bool { return portal.ID == id }); index >= 0 { + change(&t.portals[index]) + } +} + +// ModifyClient changes an application without a request, for a test that needs an application that +// broke. +func (t *Tenant) ModifyClient(id string, change func(client *portals.Client)) { + t.mu.Lock() + defer t.mu.Unlock() + + if index := slices.IndexFunc(t.clients, func(record clientRecord) bool { return record.ID == id }); index >= 0 { + change(&t.clients[index].Client) + } +} + +// DeleteGrant removes a client grant without a request. +func (t *Tenant) DeleteGrant(id string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.grants = slices.DeleteFunc(t.grants, func(grant portals.ClientGrant) bool { return grant.ID == id }) +} + +// DeleteResourceServer removes a resource server without a request. +func (t *Tenant) DeleteResourceServer(id string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.resourceServers = slices.DeleteFunc(t.resourceServers, func(server portals.ResourceServer) bool { return server.ID == id }) +} diff --git a/internal/portals/portalstest/tenant.go b/internal/portals/portalstest/tenant.go new file mode 100644 index 000000000..c6d7b655d --- /dev/null +++ b/internal/portals/portalstest/tenant.go @@ -0,0 +1,1118 @@ +// Package portalstest provides an in-memory tenant for testing code that uses the portals +// stores. It enforces the rules setup and audit depend on: a duplicate resource server +// identifier, portal slug or organization name conflicts, the secret of an application is only +// returned when it is created or read on its own, and a write probe with an empty body is a +// validation error, as it is on the Management API. +package portalstest + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "slices" + "strings" + "sync" + + "github.com/auth0/go-auth0/v3/management/core" + + "github.com/auth0/auth0-cli/internal/portals" +) + +// idShape is the look of an ID the Management API returns. IDs come from a counter, so a run +// can be repeated and its output compared byte for byte. +type idShape struct { + prefix string + length int +} + +var ( + idShapes = map[string]idShape{ + "rs": {prefix: "", length: 24}, + "cli": {prefix: "", length: 32}, + "cgr": {prefix: "cgr_", length: 16}, + } + + formIDPattern = regexp.MustCompile(`^ap_[A-Za-z0-9]{22}$`) +) + +const defaultIDLength = 22 + +// Tenant is an in-memory tenant that implements portals.API. +// +// A request is named like the Management API route behind it, "METHOD path" without the query +// and with the IDs in place, for example "POST clients" or "PATCH client-grants/cgr_1". Calls, +// Deny and FailNext use these names. +type Tenant struct { + mu sync.Mutex + + nextID int + calls []string + + denials map[string]string + failures map[string]int + + resourceServers []portals.ResourceServer + clients []clientRecord + grants []portals.ClientGrant + vaults []vaultRecord + forms []formRecord + portals []portals.Portal + users []portals.User + organizations []portals.Organization + members map[string][]string + orgConnections map[string][]string + orgRoles map[string][]string + roles []roleRecord + connections []connectionRecord + customDomain *portals.CustomDomain +} + +type clientRecord struct { + portals.Client + enabledConnections []string +} + +type vaultRecord struct { + portals.VaultConnection + setup portals.VaultSetup + // The scopes the vault session carries, set when the setup is sent. + mintedWithScopes []string +} + +type formRecord struct { + portals.Form + flowID string +} + +type roleRecord struct { + portals.Role + permissions []portals.Permission +} + +type connectionRecord struct { + portals.Connection + strategy string +} + +var _ portals.API = (*Tenant)(nil) + +// New returns an empty tenant. +func New() *Tenant { + return &Tenant{ + nextID: 1, + denials: map[string]string{}, + failures: map[string]int{}, + members: map[string][]string{}, + orgConnections: map[string][]string{}, + orgRoles: map[string][]string{}, + } +} + +// Deny makes every request named key fail with the Management API's scope error. +func (t *Tenant) Deny(key, message string) { + t.mu.Lock() + defer t.mu.Unlock() + + t.denials[key] = message +} + +// FailNext makes the next request named key fail once with the status. +func (t *Tenant) FailNext(key string, status int) { + t.mu.Lock() + defer t.mu.Unlock() + + t.failures[key] = status +} + +// Calls returns the requests received, in order. +func (t *Tenant) Calls() []string { + t.mu.Lock() + defer t.mu.Unlock() + + return slices.Clone(t.calls) +} + +// Writes returns the requests received that are not reads, in order. +func (t *Tenant) Writes() []string { + var writes []string + for _, call := range t.Calls() { + if !strings.HasPrefix(call, http.MethodGet+" ") { + writes = append(writes, call) + } + } + + return writes +} + +// begin records a request and returns the error it must fail with, if any. The caller holds the lock. +func (t *Tenant) begin(key string) error { + if err := t.record(key); err != nil { + return err + } + + return t.takeFailure(key) +} + +// record records a request and returns the scope error if the request is denied. +func (t *Tenant) record(key string) error { + t.calls = append(t.calls, key) + + if message, denied := t.denials[key]; denied { + return withCause(&portals.APIError{Status: http.StatusForbidden, Code: "insufficient_scope", Message: message}) + } + + return nil +} + +// takeFailure returns the failure set for the request, once. +func (t *Tenant) takeFailure(key string) error { + if status, failing := t.failures[key]; failing { + delete(t.failures, key) + return withCause(&portals.APIError{Status: status, Message: "simulated failure"}) + } + + return nil +} + +func (t *Tenant) newID(kind string) string { + t.nextID++ + + shape, known := idShapes[kind] + if !known { + shape = idShape{prefix: kind + "_", length: defaultIDLength} + } + + return fmt.Sprintf("%s%0*d", shape.prefix, shape.length, t.nextID) +} + +// withCause gives an error the SDK error that the clients of the CLI keep in it, so the CLI classifies +// the failures of the fake as it does those of the API. +func withCause(apiErr *portals.APIError) *portals.APIError { + apiErr.Err = core.NewAPIError(apiErr.Status, nil, errors.New(apiErr.Message)) + + return apiErr +} + +func notFound(message string) error { + return withCause(&portals.APIError{Status: http.StatusNotFound, Message: message}) +} + +func conflict(message string) error { + return withCause(&portals.APIError{Status: http.StatusConflict, Message: message}) +} + +// clone copies a value completely, so a caller cannot change the tenant through what it was given. +func clone[T any](value T) T { + var copied T + + data, err := json.Marshal(value) + if err != nil { + panic(fmt.Sprintf("portalstest: cannot copy %T: %v", value, err)) + } + + if err := json.Unmarshal(data, &copied); err != nil { + panic(fmt.Sprintf("portalstest: cannot copy %T: %v", value, err)) + } + + return copied +} + +// ListResourceServers implements portals.ResourceServerStore. +func (t *Tenant) ListResourceServers(_ context.Context) ([]portals.ResourceServer, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET resource-servers"); err != nil { + return nil, err + } + + return clone(t.resourceServers), nil +} + +// CreateResourceServer implements portals.ResourceServerStore. +func (t *Tenant) CreateResourceServer(_ context.Context, spec portals.ResourceServerSpec) (portals.ResourceServer, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST resource-servers"); err != nil { + return portals.ResourceServer{}, err + } + + for _, server := range t.resourceServers { + if server.Identifier == spec.Identifier { + return portals.ResourceServer{}, conflict("The resource server identifier already exists") + } + } + + server := portals.ResourceServer{ID: t.newID("rs"), Identifier: spec.Identifier, Name: spec.Name} + t.resourceServers = append(t.resourceServers, server) + + return server, nil +} + +// ListClients implements portals.ClientStore. Like the list endpoint of the setup, it returns only +// the ID, name, metadata and session transfer. +func (t *Tenant) ListClients(_ context.Context) ([]portals.Client, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET clients"); err != nil { + return nil, err + } + + listed := make([]portals.Client, 0, len(t.clients)) + for _, record := range t.clients { + listed = append(listed, clone(portals.Client{ + ID: record.ID, + ClientSettings: portals.ClientSettings{ + Name: record.Name, + Metadata: record.Metadata, + SessionTransfer: record.SessionTransfer, + }, + })) + } + + return listed, nil +} + +// ReadClient implements portals.ClientStore. +func (t *Tenant) ReadClient(_ context.Context, id string) (portals.Client, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET clients/" + id); err != nil { + return portals.Client{}, err + } + + record, found := t.findClient(id) + if !found { + return portals.Client{}, notFound("not found") + } + + client := clone(record.Client) + client.Secret = "" + + return client, nil +} + +// ReadClientSecret implements portals.ClientStore. +func (t *Tenant) ReadClientSecret(_ context.Context, id string) (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET clients/" + id); err != nil { + return "", err + } + + record, found := t.findClient(id) + if !found { + return "", notFound("not found") + } + + return record.Secret, nil +} + +// CreateClient implements portals.ClientStore. +func (t *Tenant) CreateClient(_ context.Context, settings portals.ClientSettings) (portals.Client, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST clients"); err != nil { + return portals.Client{}, err + } + + id := t.newID("cli") + client := portals.Client{ + ID: id, + Secret: fmt.Sprintf("%064d", t.nextID), + ClientSettings: clone(settings), + } + t.clients = append(t.clients, clientRecord{Client: client}) + + return clone(client), nil +} + +// UpdateClient implements portals.ClientStore. +func (t *Tenant) UpdateClient(_ context.Context, id string, settings portals.ClientSettings) error { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("PATCH clients/" + id); err != nil { + return err + } + + index := slices.IndexFunc(t.clients, func(record clientRecord) bool { return record.ID == id }) + if index < 0 { + return notFound("not found") + } + + mergeClientSettings(&t.clients[index].ClientSettings, clone(settings)) + + return nil +} + +// mergeClientSettings copies the settings that are set over the current ones. A list left empty is +// not set, like in the requests of the real client, so it cannot clear the current one. +func mergeClientSettings(current *portals.ClientSettings, changes portals.ClientSettings) { + if changes.Name != "" { + current.Name = changes.Name + } + + if changes.AppType != "" { + current.AppType = changes.AppType + } + + if changes.TokenEndpointAuthMethod != "" { + current.TokenEndpointAuthMethod = changes.TokenEndpointAuthMethod + } + + if changes.OrganizationUsage != "" { + current.OrganizationUsage = changes.OrganizationUsage + } + + if changes.OrganizationRequireBehavior != "" { + current.OrganizationRequireBehavior = changes.OrganizationRequireBehavior + } + + if changes.IsFirstParty != nil { + current.IsFirstParty = changes.IsFirstParty + } + + if changes.OIDCConformant != nil { + current.OIDCConformant = changes.OIDCConformant + } + + if changes.IPHeaderTrusted != nil { + current.IPHeaderTrusted = changes.IPHeaderTrusted + } + + if changes.RequireProofOfPossession != nil { + current.RequireProofOfPossession = changes.RequireProofOfPossession + } + + if len(changes.Callbacks) > 0 { + current.Callbacks = changes.Callbacks + } + + if len(changes.AllowedLogoutURLs) > 0 { + current.AllowedLogoutURLs = changes.AllowedLogoutURLs + } + + if len(changes.GrantTypes) > 0 { + current.GrantTypes = changes.GrantTypes + } + + if changes.Metadata != nil { + current.Metadata = changes.Metadata + } + + if changes.RefreshToken != nil { + current.RefreshToken = changes.RefreshToken + } + + if changes.SessionTransfer != nil { + current.SessionTransfer = changes.SessionTransfer + } + + if changes.OIDCLogout != nil { + current.OIDCLogout = changes.OIDCLogout + } +} + +func (t *Tenant) findClient(id string) (clientRecord, bool) { + index := slices.IndexFunc(t.clients, func(record clientRecord) bool { return record.ID == id }) + if index < 0 { + return clientRecord{}, false + } + + return t.clients[index], true +} + +// ListClientGrants implements portals.ClientGrantStore. +func (t *Tenant) ListClientGrants(_ context.Context, clientID string) ([]portals.ClientGrant, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET client-grants"); err != nil { + return nil, err + } + + var listed []portals.ClientGrant + for _, grant := range t.grants { + if grant.ClientID == clientID { + listed = append(listed, clone(grant)) + } + } + + return listed, nil +} + +// CreateClientGrant implements portals.ClientGrantStore. +func (t *Tenant) CreateClientGrant(_ context.Context, spec portals.ClientGrantSpec) (portals.ClientGrant, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST client-grants"); err != nil { + return portals.ClientGrant{}, err + } + + grant := portals.ClientGrant{ + ID: t.newID("cgr"), + ClientID: spec.ClientID, + Audience: spec.Audience, + Scope: slices.Clone(spec.Scope), + } + t.grants = append(t.grants, grant) + + return clone(grant), nil +} + +// UpdateClientGrantScope implements portals.ClientGrantStore. +func (t *Tenant) UpdateClientGrantScope(_ context.Context, id string, scope []string) error { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("PATCH client-grants/" + id); err != nil { + return err + } + + index := slices.IndexFunc(t.grants, func(grant portals.ClientGrant) bool { return grant.ID == id }) + if index < 0 { + return notFound("The grant does not exist") + } + + t.grants[index].Scope = slices.Clone(scope) + + return nil +} + +// DeleteClientGrant implements portals.ClientGrantStore. +func (t *Tenant) DeleteClientGrant(_ context.Context, id string) error { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("DELETE client-grants/" + id); err != nil { + return err + } + + index := slices.IndexFunc(t.grants, func(grant portals.ClientGrant) bool { return grant.ID == id }) + if index < 0 { + return notFound("The grant does not exist") + } + + t.grants = slices.Delete(t.grants, index, index+1) + + return nil +} + +// ListVaultConnections implements portals.VaultStore. +func (t *Tenant) ListVaultConnections(_ context.Context) ([]portals.VaultConnection, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET flows/vault/connections"); err != nil { + return nil, err + } + + listed := make([]portals.VaultConnection, 0, len(t.vaults)) + for _, vault := range t.vaults { + listed = append(listed, vault.VaultConnection) + } + + return listed, nil +} + +// CreateVaultConnection implements portals.VaultStore. +func (t *Tenant) CreateVaultConnection(_ context.Context, name string, setup portals.VaultSetup) (portals.VaultConnection, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST flows/vault/connections"); err != nil { + return portals.VaultConnection{}, err + } + + vault := vaultRecord{ + VaultConnection: portals.VaultConnection{ID: t.newID("ac"), Name: name}, + setup: setup, + } + t.vaults = append(t.vaults, vault) + + return vault.VaultConnection, nil +} + +// UpdateVaultConnection implements portals.VaultStore. +func (t *Tenant) UpdateVaultConnection(_ context.Context, id string, setup portals.VaultSetup) error { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("PATCH flows/vault/connections/" + id); err != nil { + return err + } + + index := slices.IndexFunc(t.vaults, func(vault vaultRecord) bool { return vault.ID == id }) + if index < 0 { + return notFound("Connection not found") + } + + t.vaults[index].setup = setup + t.vaults[index].mintedWithScopes = t.managementScopes(setup.ClientID) + + return nil +} + +// managementScopes are the scopes a token of the client would carry for the Management API. +func (t *Tenant) managementScopes(clientID string) []string { + for _, grant := range t.grants { + if grant.ClientID == clientID && strings.HasSuffix(grant.Audience, "/api/v2/") { + return slices.Clone(grant.Scope) + } + } + + return nil +} + +// VaultMintedScopes returns the scopes the session of a vault connection carries. The session is +// minted when the connection is created or its setup is sent again. +func (t *Tenant) VaultMintedScopes(id string) []string { + t.mu.Lock() + defer t.mu.Unlock() + + for _, vault := range t.vaults { + if vault.ID == id { + return slices.Clone(vault.mintedWithScopes) + } + } + + return nil +} + +// ListForms implements portals.FormStore. +func (t *Tenant) ListForms(_ context.Context) ([]portals.Form, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET forms"); err != nil { + return nil, err + } + + listed := make([]portals.Form, 0, len(t.forms)) + for _, form := range t.forms { + listed = append(listed, form.Form) + } + + return listed, nil +} + +// ReadForm implements portals.FormStore. +func (t *Tenant) ReadForm(_ context.Context, id string) (portals.FormDetail, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET forms/" + id); err != nil { + return portals.FormDetail{}, err + } + + if !formIDPattern.MatchString(id) { + return portals.FormDetail{}, withCause(&portals.APIError{ + Status: http.StatusBadRequest, + Message: "The provided input does not satisfy the expected schema", + }) + } + + index := slices.IndexFunc(t.forms, func(form formRecord) bool { return form.ID == id }) + if index < 0 { + return portals.FormDetail{}, notFound("Form not found") + } + + return portals.FormDetail{ID: id, FlowID: t.forms[index].flowID}, nil +} + +// ImportForm implements portals.FormStore. A document is a form with the name at form.name. +func (t *Tenant) ImportForm(_ context.Context, document any) (portals.ImportedForm, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST forms/import"); err != nil { + return portals.ImportedForm{}, err + } + + var parsed struct { + Form struct { + Name string `json:"name"` + } `json:"form"` + } + + data, err := json.Marshal(document) + if err != nil { + return portals.ImportedForm{}, fmt.Errorf("portalstest: cannot read the form document: %w", err) + } + + if err := json.Unmarshal(data, &parsed); err != nil { + return portals.ImportedForm{}, fmt.Errorf("portalstest: cannot read the form document: %w", err) + } + + form := formRecord{Form: portals.Form{ID: t.newID("ap"), Name: parsed.Form.Name}, flowID: t.newID("af")} + t.forms = append(t.forms, form) + + return portals.ImportedForm{ID: form.ID, FlowID: form.flowID}, nil +} + +// ListPortals implements portals.PortalStore. +func (t *Tenant) ListPortals(_ context.Context) ([]portals.PortalSummary, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET portals"); err != nil { + return nil, err + } + + listed := make([]portals.PortalSummary, 0, len(t.portals)) + for _, portal := range t.portals { + listed = append(listed, portals.PortalSummary{ID: portal.ID, Slug: portal.Slug, Name: portal.Name}) + } + + return listed, nil +} + +// ReadPortal implements portals.PortalStore. +func (t *Tenant) ReadPortal(_ context.Context, id string) (portals.Portal, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET portals/" + id); err != nil { + return portals.Portal{}, err + } + + index := slices.IndexFunc(t.portals, func(portal portals.Portal) bool { return portal.ID == id }) + if index < 0 { + return portals.Portal{}, notFound("not found") + } + + return clone(t.portals[index]), nil +} + +// CreatePortal implements portals.PortalStore. Like the API, it keeps only the ID of the application. +func (t *Tenant) CreatePortal(_ context.Context, request portals.CreatePortalRequest) (portals.Portal, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST portals"); err != nil { + return portals.Portal{}, err + } + + for _, portal := range t.portals { + if portal.Slug == request.Slug { + return portals.Portal{}, withCause(&portals.APIError{ + Status: http.StatusConflict, + Code: "portal_conflict", + Message: "Slug already in use", + }) + } + } + + portal := portals.Portal{ + ID: t.newID("up"), + Slug: request.Slug, + Name: request.Name, + Navigation: clone(request.Navigation), + Pages: clone(request.Pages), + } + if request.Client != nil { + portal.Client = &portals.PortalClient{ClientID: request.Client.ClientID} + } + + t.portals = append(t.portals, portal) + + return clone(portal), nil +} + +// UpdatePortal implements portals.PortalStore. +func (t *Tenant) UpdatePortal(_ context.Context, id string, patch portals.PortalPatch) error { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("PATCH portals/" + id); err != nil { + return err + } + + index := slices.IndexFunc(t.portals, func(portal portals.Portal) bool { return portal.ID == id }) + if index < 0 { + return notFound("not found") + } + + portal := &t.portals[index] + if patch.Client != nil { + portal.Client = &portals.PortalClient{ClientID: patch.Client.ClientID} + } + + if patch.Navigation != nil { + portal.Navigation = clone(patch.Navigation) + } + + if patch.Pages != nil { + portal.Pages = clone(patch.Pages) + } + + return nil +} + +// SetCustomDomain sets the default custom domain of the tenant. A nil domain removes it. +func (t *Tenant) SetCustomDomain(domain *portals.CustomDomain) { + t.mu.Lock() + defer t.mu.Unlock() + + t.customDomain = clone(domain) +} + +// ReadDefaultCustomDomain implements portals.CustomDomainStore. +func (t *Tenant) ReadDefaultCustomDomain(_ context.Context) (portals.CustomDomain, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET custom-domains/default"); err != nil { + return portals.CustomDomain{}, err + } + + if t.customDomain == nil { + return portals.CustomDomain{}, notFound("Not Found") + } + + return *t.customDomain, nil +} + +// SearchUsersByEmail implements portals.UserStore. The search matches the text anywhere in the +// email, like the real search. +func (t *Tenant) SearchUsersByEmail(_ context.Context, email string) ([]portals.User, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET users"); err != nil { + return nil, err + } + + var found []portals.User + for _, user := range t.users { + if strings.Contains(user.Email, email) { + found = append(found, user) + } + } + + return found, nil +} + +// ReadUser implements portals.UserStore. +func (t *Tenant) ReadUser(_ context.Context, id string) (portals.User, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET users/" + id); err != nil { + return portals.User{}, err + } + + index := slices.IndexFunc(t.users, func(user portals.User) bool { return user.ID == id }) + if index < 0 { + return portals.User{}, notFound("The user does not exist.") + } + + return t.users[index], nil +} + +// ListUserOrganizations implements portals.UserStore. +func (t *Tenant) ListUserOrganizations(_ context.Context, userID string) ([]portals.Organization, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET users/" + userID + "/organizations"); err != nil { + return nil, err + } + + var memberships []portals.Organization + for _, organization := range t.organizations { + if slices.Contains(t.members[organization.ID], userID) { + memberships = append(memberships, organization) + } + } + + return memberships, nil +} + +// ListOrganizations implements portals.OrganizationStore. +func (t *Tenant) ListOrganizations(_ context.Context) ([]portals.Organization, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET organizations"); err != nil { + return nil, err + } + + return slices.Clone(t.organizations), nil +} + +// ReadOrganization implements portals.OrganizationStore. +func (t *Tenant) ReadOrganization(_ context.Context, id string) (portals.Organization, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET organizations/" + id); err != nil { + return portals.Organization{}, err + } + + index := slices.IndexFunc(t.organizations, func(organization portals.Organization) bool { return organization.ID == id }) + if index < 0 { + return portals.Organization{}, notFound("The organization does not exist.") + } + + return t.organizations[index], nil +} + +// CreateOrganization implements portals.OrganizationStore. +func (t *Tenant) CreateOrganization(_ context.Context, spec portals.OrganizationSpec) (portals.Organization, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST organizations"); err != nil { + return portals.Organization{}, err + } + + for _, organization := range t.organizations { + if organization.Name == spec.Name { + return portals.Organization{}, conflict("The organization name is already in use.") + } + } + + organization := portals.Organization{ID: t.newID("org"), Name: spec.Name, DisplayName: spec.DisplayName} + t.organizations = append(t.organizations, organization) + + return organization, nil +} + +// ListOrganizationConnections implements portals.OrganizationStore. +func (t *Tenant) ListOrganizationConnections(_ context.Context, id string) ([]string, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET organizations/" + id + "/enabled_connections"); err != nil { + return nil, err + } + + return slices.Clone(t.orgConnections[id]), nil +} + +// EnableOrganizationConnection implements portals.OrganizationStore. +func (t *Tenant) EnableOrganizationConnection(_ context.Context, id, connectionID string) error { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST organizations/" + id + "/enabled_connections"); err != nil { + return err + } + + t.orgConnections[id] = append(t.orgConnections[id], connectionID) + + return nil +} + +// AddOrganizationMember implements portals.OrganizationStore. +func (t *Tenant) AddOrganizationMember(_ context.Context, id, userID string) error { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST organizations/" + id + "/members"); err != nil { + return err + } + + if !slices.Contains(t.members[id], userID) { + t.members[id] = append(t.members[id], userID) + } + + return nil +} + +// ListMemberRoles implements portals.OrganizationStore. +func (t *Tenant) ListMemberRoles(_ context.Context, id, userID string) ([]portals.Role, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET organizations/" + id + "/members/" + userID + "/roles"); err != nil { + return nil, err + } + + var assigned []portals.Role + for _, roleID := range t.orgRoles[id+":"+userID] { + if index := slices.IndexFunc(t.roles, func(role roleRecord) bool { return role.ID == roleID }); index >= 0 { + assigned = append(assigned, t.roles[index].Role) + } + } + + return assigned, nil +} + +// AssignMemberRole implements portals.OrganizationStore. +func (t *Tenant) AssignMemberRole(_ context.Context, id, userID, roleID string) error { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST organizations/" + id + "/members/" + userID + "/roles"); err != nil { + return err + } + + key := id + ":" + userID + t.orgRoles[key] = append(t.orgRoles[key], roleID) + + return nil +} + +// ListRoles implements portals.RoleStore. +func (t *Tenant) ListRoles(_ context.Context) ([]portals.Role, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET roles"); err != nil { + return nil, err + } + + listed := make([]portals.Role, 0, len(t.roles)) + for _, role := range t.roles { + listed = append(listed, role.Role) + } + + return listed, nil +} + +// CreateRole implements portals.RoleStore. +func (t *Tenant) CreateRole(_ context.Context, spec portals.RoleSpec) (portals.Role, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST roles"); err != nil { + return portals.Role{}, err + } + + role := roleRecord{Role: portals.Role{ID: t.newID("rol"), Name: spec.Name}} + t.roles = append(t.roles, role) + + return role.Role, nil +} + +// ListRolePermissions implements portals.RoleStore. +func (t *Tenant) ListRolePermissions(_ context.Context, roleID string) ([]portals.Permission, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET roles/" + roleID + "/permissions"); err != nil { + return nil, err + } + + index := slices.IndexFunc(t.roles, func(role roleRecord) bool { return role.ID == roleID }) + if index < 0 { + return nil, nil + } + + return slices.Clone(t.roles[index].permissions), nil +} + +// AddRolePermissions implements portals.RoleStore. +func (t *Tenant) AddRolePermissions(_ context.Context, roleID string, permissions []portals.Permission) error { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("POST roles/" + roleID + "/permissions"); err != nil { + return err + } + + index := slices.IndexFunc(t.roles, func(role roleRecord) bool { return role.ID == roleID }) + if index < 0 { + return notFound("The role does not exist") + } + + t.roles[index].permissions = append(t.roles[index].permissions, permissions...) + + return nil +} + +// ListDatabaseConnections implements portals.ConnectionStore. +func (t *Tenant) ListDatabaseConnections(_ context.Context) ([]portals.Connection, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET connections"); err != nil { + return nil, err + } + + var listed []portals.Connection + for _, connection := range t.connections { + if connection.strategy == "auth0" { + listed = append(listed, connection.Connection) + } + } + + return listed, nil +} + +// ListClientConnections implements portals.ConnectionStore. +func (t *Tenant) ListClientConnections(_ context.Context, clientID string) ([]portals.Connection, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.begin("GET clients/" + clientID + "/connections"); err != nil { + return nil, err + } + + record, found := t.findClient(clientID) + if !found { + return nil, notFound("not found") + } + + var listed []portals.Connection + for _, connectionID := range record.enabledConnections { + if index := slices.IndexFunc(t.connections, func(connection connectionRecord) bool { return connection.ID == connectionID }); index >= 0 { + listed = append(listed, t.connections[index].Connection) + } + } + + return listed, nil +} + +// Probe implements portals.Prober. +func (t *Tenant) Probe(_ context.Context, method, path string, body any) error { + t.mu.Lock() + defer t.mu.Unlock() + + key := method + " " + strings.SplitN(path, "?", 2)[0] + if err := t.record(key); err != nil { + return err + } + + // Like the real API, a create with an empty body is a validation error and not a success. + // Preflight relies on this to tell a present scope from a missing one. The check comes before + // the failures a test sets, so a probe never uses up the failure meant for the real request. + if method == http.MethodPost && isEmptyBody(body) { + return withCause(&portals.APIError{ + Status: http.StatusBadRequest, + Message: "Payload validation error: 'Missing required property'", + }) + } + + if err := t.takeFailure(key); err != nil { + return err + } + + if method != http.MethodGet && strings.Contains(path, "preflight") { + return notFound("The resource does not exist") + } + + return nil +} + +func isEmptyBody(body any) bool { + if body == nil { + return false + } + + data, err := json.Marshal(body) + + return err == nil && string(data) == "{}" +} diff --git a/internal/portals/portalstest/tenant_test.go b/internal/portals/portalstest/tenant_test.go new file mode 100644 index 000000000..1a9f78bbc --- /dev/null +++ b/internal/portals/portalstest/tenant_test.go @@ -0,0 +1,321 @@ +package portalstest_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +func TestTenant_IDs(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + + server, err := tenant.CreateResourceServer(ctx, portals.ResourceServerSpec{Name: "API", Identifier: "https://acme/me/"}) + require.NoError(t, err) + client, err := tenant.CreateClient(ctx, portals.ClientSettings{Name: "App"}) + require.NoError(t, err) + grant, err := tenant.CreateClientGrant(ctx, portals.ClientGrantSpec{ClientID: client.ID, Audience: server.Identifier}) + require.NoError(t, err) + vault, err := tenant.CreateVaultConnection(ctx, "Vault", portals.VaultSetup{}) + require.NoError(t, err) + form, err := tenant.ImportForm(ctx, map[string]any{"form": map[string]any{"name": "Profile"}}) + require.NoError(t, err) + portal, err := tenant.CreatePortal(ctx, portals.CreatePortalRequest{Slug: "acme", Client: &portals.PortalClient{ClientID: client.ID}}) + require.NoError(t, err) + + // One counter feeds every kind of ID, in the order the resources are created, with the shape + // of the real IDs. Forms use two numbers: one for the form and one for its flow. + assert.Equal(t, "000000000000000000000002", server.ID) + assert.Equal(t, "00000000000000000000000000000003", client.ID) + assert.Equal(t, "cgr_0000000000000004", grant.ID) + assert.Equal(t, "ac_0000000000000000000005", vault.ID) + assert.Equal(t, "ap_0000000000000000000006", form.ID) + assert.Equal(t, "af_0000000000000000000007", form.FlowID) + assert.Equal(t, "up_0000000000000000000008", portal.ID) +} + +func TestTenant_Conflicts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + create func(ctx context.Context, tenant *portalstest.Tenant) error + }{ + { + name: "a resource server identifier already in use", + create: func(ctx context.Context, tenant *portalstest.Tenant) error { + _, err := tenant.CreateResourceServer(ctx, portals.ResourceServerSpec{Identifier: "https://acme/me/"}) + return err + }, + }, + { + name: "a portal slug already in use", + create: func(ctx context.Context, tenant *portalstest.Tenant) error { + _, err := tenant.CreatePortal(ctx, portals.CreatePortalRequest{Slug: "acme", Client: &portals.PortalClient{}}) + return err + }, + }, + { + name: "an organization name already in use", + create: func(ctx context.Context, tenant *portalstest.Tenant) error { + _, err := tenant.CreateOrganization(ctx, portals.OrganizationSpec{Name: "acme"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + tenant.AddResourceServer(portals.ResourceServer{ID: "rs_1", Identifier: "https://acme/me/"}) + tenant.AddPortal(portals.Portal{ID: "up_1", Slug: "acme"}) + tenant.AddOrganization(portals.Organization{ID: "org_1", Name: "acme"}) + + err := tt.create(ctx, tenant) + + assert.True(t, portals.IsStatus(err, http.StatusConflict), "got %v", err) + }) + } +} + +func TestTenant_ClientSecrets(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + created, err := tenant.CreateClient(ctx, portals.ClientSettings{Name: "App", Metadata: map[string]string{"role": "portal"}}) + require.NoError(t, err) + + listed, err := tenant.ListClients(ctx) + require.NoError(t, err) + read, err := tenant.ReadClient(ctx, created.ID) + require.NoError(t, err) + secret, err := tenant.ReadClientSecret(ctx, created.ID) + require.NoError(t, err) + + assert.NotEmpty(t, created.Secret, "a create returns the secret") + assert.Empty(t, listed[0].Secret) + assert.Empty(t, read.Secret, "a read of the settings does not return the secret") + assert.Equal(t, created.Secret, secret) + assert.Equal(t, map[string]string{"role": "portal"}, listed[0].Metadata) + assert.Nil(t, listed[0].Callbacks, "a list returns only the ID, name, metadata and session transfer") +} + +func TestTenant_UpdateClientKeepsWhatIsNotSet(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + created, err := tenant.CreateClient(ctx, portals.ClientSettings{ + Name: "App", + Callbacks: []string{"https://old/callback"}, + OrganizationUsage: "allow", + SessionTransfer: &portals.SessionTransfer{AllowedAuthenticationMethods: []string{"cookie"}}, + }) + require.NoError(t, err) + + require.NoError(t, tenant.UpdateClient(ctx, created.ID, portals.ClientSettings{Callbacks: []string{"https://new/callback"}})) + + read, err := tenant.ReadClient(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, []string{"https://new/callback"}, read.Callbacks) + assert.Equal(t, "App", read.Name) + assert.Equal(t, "allow", read.OrganizationUsage) + assert.Equal(t, []string{"cookie"}, read.SessionTransfer.AllowedAuthenticationMethods) +} + +func TestTenant_ReadForm(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id string + wantStatus int + }{ + {name: "an ID the API cannot match is a validation error", id: "not-a-form-id", wantStatus: http.StatusBadRequest}, + {name: "a form that does not exist", id: "ap_0000000000000000000099", wantStatus: http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := portalstest.New().ReadForm(context.Background(), tt.id) + + assert.True(t, portals.IsStatus(err, tt.wantStatus), "got %v", err) + }) + } +} + +func TestTenant_VaultSessionCarriesTheScopesOfItsGrant(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + client, err := tenant.CreateClient(ctx, portals.ClientSettings{Name: "Vault app"}) + require.NoError(t, err) + vault, err := tenant.CreateVaultConnection(ctx, "Vault", portals.VaultSetup{ClientID: client.ID}) + require.NoError(t, err) + _, err = tenant.CreateClientGrant(ctx, portals.ClientGrantSpec{ + ClientID: client.ID, + Audience: "https://acme.us.auth0.com/api/v2/", + Scope: []string{"update:users"}, + }) + require.NoError(t, err) + assert.Empty(t, tenant.VaultMintedScopes(vault.ID), "the session was minted before the grant existed") + + require.NoError(t, tenant.UpdateVaultConnection(ctx, vault.ID, portals.VaultSetup{ClientID: client.ID})) + + assert.Equal(t, []string{"update:users"}, tenant.VaultMintedScopes(vault.ID)) +} + +func TestTenant_Probe(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + path string + body any + wantStatus int + }{ + {name: "a read succeeds", method: http.MethodGet, path: "clients?per_page=1"}, + {name: "a create with an empty body is a validation error", method: http.MethodPost, path: "clients", body: map[string]any{}, wantStatus: http.StatusBadRequest}, + {name: "an update of a resource that does not exist", method: http.MethodPatch, path: "client-grants/cgr_preflight0000000", body: map[string]any{}, wantStatus: http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := portalstest.New().Probe(context.Background(), tt.method, tt.path, tt.body) + + if tt.wantStatus == 0 { + assert.NoError(t, err) + return + } + assert.True(t, portals.IsStatus(err, tt.wantStatus), "got %v", err) + }) + } +} + +func TestTenant_DenyAndFailNext(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + tenant.Deny("POST clients", "Insufficient scope, expected any of: create:clients") + tenant.FailNext("POST portals", http.StatusInternalServerError) + + _, deniedErr := tenant.CreateClient(ctx, portals.ClientSettings{Name: "App"}) + probeErr := tenant.Probe(ctx, http.MethodPost, "clients", map[string]any{}) + _, firstErr := tenant.CreatePortal(ctx, portals.CreatePortalRequest{Slug: "a", Client: &portals.PortalClient{}}) + _, secondErr := tenant.CreatePortal(ctx, portals.CreatePortalRequest{Slug: "a", Client: &portals.PortalClient{}}) + + assert.Equal(t, "insufficient_scope", portals.CodeOf(deniedErr)) + assert.True(t, portals.IsStatus(deniedErr, http.StatusForbidden)) + assert.True(t, portals.IsStatus(probeErr, http.StatusForbidden), "a probe is denied like the write it stands for") + assert.True(t, portals.IsStatus(firstErr, http.StatusInternalServerError)) + assert.NoError(t, secondErr, "a failure happens once") + assert.Equal(t, []string{"POST clients", "POST clients", "POST portals", "POST portals"}, tenant.Calls()) + assert.Equal(t, tenant.Calls(), tenant.Writes()) +} + +func TestTenant_ReturnsCopies(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + created, err := tenant.CreatePortal(ctx, portals.CreatePortalRequest{ + Slug: "acme", + Client: &portals.PortalClient{ClientID: "client"}, + Pages: &portals.Pages{Default: "profile"}, + }) + require.NoError(t, err) + + created.Pages.Default = "changed by the caller" + + read, err := tenant.ReadPortal(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, "profile", read.Pages.Default) +} + +func TestPrompter(t *testing.T) { + t.Parallel() + + t.Run("a question nobody answers fails the test", func(t *testing.T) { + t.Parallel() + + prompter := &portalstest.Prompter{} + + _, askErr := prompter.Ask("Name", "", "") + _, confirmErr := prompter.Confirm("Sure?", true) + _, chooseErr := prompter.Choose("Which?", nil) + _, checklistErr := prompter.Checklist("Which ones?", nil) + + for _, err := range []error{askErr, confirmErr, chooseErr, checklistErr} { + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected question") + } + + assert.Equal(t, []string{"Name", "Sure?", "Which?", "Which ones?"}, prompter.Questions()) + }) + + t.Run("a question with an answer gets it", func(t *testing.T) { + t.Parallel() + + prompter := &portalstest.Prompter{ + AskFunc: func(_, defaultValue, _ string) (string, error) { return defaultValue + "!", nil }, + } + + answer, err := prompter.Ask("Name", "Acme", "") + + require.NoError(t, err) + assert.Equal(t, "Acme!", answer) + }) +} + +func TestReporter(t *testing.T) { + t.Parallel() + + reporter := &portalstest.Reporter{} + + reporter.Section("Forms") + reporter.Item("Profile", "created") + reporter.Warn("The slug is taken.") + require.NoError(t, reporter.Task("Creating portal", func() error { return nil })) + + assert.Equal(t, []string{ + "section: Forms", + "item: Profile | created", + "warn: The slug is taken.", + "task: Creating portal", + }, reporter.Lines()) + assert.True(t, reporter.Contains("warn", "slug")) + assert.False(t, reporter.Contains("error", "slug")) +} + +func TestTenant_AProbeDoesNotUseUpTheFailureMeantForTheRealRequest(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + tenant.FailNext("POST forms/import", http.StatusInternalServerError) + + probeErr := tenant.Probe(ctx, http.MethodPost, "forms/import", map[string]any{}) + _, importErr := tenant.ImportForm(ctx, map[string]any{"form": map[string]any{"name": "Profile"}}) + + assert.True(t, portals.IsStatus(probeErr, http.StatusBadRequest), "the probe is a validation error, as on the real API") + assert.True(t, portals.IsStatus(importErr, http.StatusInternalServerError), "the failure is still there for the real request") +} diff --git a/internal/portals/portalstest/ui.go b/internal/portals/portalstest/ui.go new file mode 100644 index 000000000..b18fd6191 --- /dev/null +++ b/internal/portals/portalstest/ui.go @@ -0,0 +1,172 @@ +package portalstest + +import ( + "fmt" + "strings" + "sync" + + "github.com/auth0/auth0-cli/internal/portals" +) + +// Prompter answers the questions of a test from functions it sets. A question with no function +// to answer it fails, so a test that asks more than it expects does not pass by accident. +type Prompter struct { + AskFunc func(question, defaultValue, example string) (string, error) + ConfirmFunc func(question string, defaultValue bool) (bool, error) + ChooseFunc func(question string, options []portals.Choice) (int, error) + ChecklistFunc func(question string, items []portals.ChecklistItem) ([]int, error) + + mu sync.Mutex + questions []string +} + +var _ portals.Prompter = (*Prompter)(nil) + +// Questions returns the questions asked so far, in order. +func (p *Prompter) Questions() []string { + p.mu.Lock() + defer p.mu.Unlock() + + return append([]string(nil), p.questions...) +} + +func (p *Prompter) record(question string) { + p.mu.Lock() + defer p.mu.Unlock() + + p.questions = append(p.questions, question) +} + +// Ask implements portals.Prompter. +func (p *Prompter) Ask(question, defaultValue, example string) (string, error) { + p.record(question) + + if p.AskFunc == nil { + return "", fmt.Errorf("portalstest: unexpected question %q", question) + } + + return p.AskFunc(question, defaultValue, example) +} + +// Confirm implements portals.Prompter. +func (p *Prompter) Confirm(question string, defaultValue bool) (bool, error) { + p.record(question) + + if p.ConfirmFunc == nil { + return false, fmt.Errorf("portalstest: unexpected question %q", question) + } + + return p.ConfirmFunc(question, defaultValue) +} + +// Choose implements portals.Prompter. +func (p *Prompter) Choose(question string, options []portals.Choice) (int, error) { + p.record(question) + + if p.ChooseFunc == nil { + return 0, fmt.Errorf("portalstest: unexpected question %q", question) + } + + return p.ChooseFunc(question, options) +} + +// Checklist implements portals.Prompter. +func (p *Prompter) Checklist(question string, items []portals.ChecklistItem) ([]int, error) { + p.record(question) + + if p.ChecklistFunc == nil { + return nil, fmt.Errorf("portalstest: unexpected question %q", question) + } + + return p.ChecklistFunc(question, items) +} + +// Reporter records what a flow shows, one line for each call. A line starts with the kind of the +// call, such as "section" or "warn", then a colon and the text. It runs the work of a task at once. +type Reporter struct { + mu sync.Mutex + lines []string +} + +var _ portals.Reporter = (*Reporter)(nil) + +// Lines returns what was shown, in order. +func (r *Reporter) Lines() []string { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]string(nil), r.lines...) +} + +// Contains reports whether a line of the given kind has the text in it. +func (r *Reporter) Contains(kind, text string) bool { + for _, line := range r.Lines() { + if strings.HasPrefix(line, kind+": ") && strings.Contains(line, text) { + return true + } + } + + return false +} + +func (r *Reporter) add(kind string, parts ...string) { + r.mu.Lock() + defer r.mu.Unlock() + + r.lines = append(r.lines, kind+": "+strings.Join(parts, " | ")) +} + +// Banner implements portals.Reporter. +func (r *Reporter) Banner(title, tenantDomain string) { r.add("banner", title, tenantDomain) } + +// Section implements portals.Reporter. +func (r *Reporter) Section(title string) { r.add("section", title) } + +// Item implements portals.Reporter. +func (r *Reporter) Item(text, outcome string) { r.add("item", text, outcome) } + +// Fixed implements portals.Reporter. +func (r *Reporter) Fixed(text, summary string) { r.add("fixed", text, summary) } + +// Result implements portals.Reporter. +func (r *Reporter) Result(text string) { r.add("result", text) } + +// Info implements portals.Reporter. +func (r *Reporter) Info(text string) { r.add("info", text) } + +// Warn implements portals.Reporter. +func (r *Reporter) Warn(text string) { r.add("warn", text) } + +// Error implements portals.Reporter. +func (r *Reporter) Error(text string) { r.add("error", text) } + +// Detail implements portals.Reporter. +func (r *Reporter) Detail(text string) { r.add("detail", text) } + +// Reference implements portals.Reporter. +func (r *Reporter) Reference(label, id, url string) { r.add("reference", label, id, url) } + +// Ready implements portals.Reporter. +func (r *Reporter) Ready(url string) { r.add("ready", url) } + +// Cancelled implements portals.Reporter. +func (r *Reporter) Cancelled(message string) { r.add("cancelled", message) } + +// Newline implements portals.Reporter. +func (r *Reporter) Newline() { r.add("newline") } + +// Table implements portals.Reporter. +func (r *Reporter) Table(headers []string, rows [][]string) { + r.add("table", strings.Join(headers, ",")) + + for _, row := range rows { + r.add("row", strings.Join(row, ",")) + } +} + +// Task implements portals.Reporter. +func (r *Reporter) Task(title string, work func() error) error { + r.add("task", title) + + return work() +} diff --git a/internal/portals/preflight.go b/internal/portals/preflight.go new file mode 100644 index 000000000..5bfc53d75 --- /dev/null +++ b/internal/portals/preflight.go @@ -0,0 +1,130 @@ +package portals + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" +) + +// PreflightResult is what the checks found. +type PreflightResult struct { + // Gaps are the checks the API refused for a missing scope, in the order of the checks. + Gaps []ScopeGap + // Unexpected are the checks that failed for another reason. + Unexpected []UnexpectedProbe +} + +// UnexpectedProbe is a check that failed for a reason other than a missing scope. +type UnexpectedProbe struct { + Probe string + Status int + Message string +} + +// RunPreflight sends the checks together and reads the answers in the order of the checks, so the +// report and the first error do not depend on which answer came first. +func RunPreflight(ctx context.Context, prober Prober, probes []Probe, tenantDomain string) (PreflightResult, error) { + failures := make([]error, len(probes)) + + forEachParallel(len(probes), func(index int) { + probe := probes[index] + failures[index] = prober.Probe(ctx, probe.Method, probe.Path, probe.Body) + }) + + var result PreflightResult + + for index, failure := range failures { + if failure == nil { + continue + } + + probe := probes[index] + + apiErr, ok := errors.AsType[*APIError](failure) + if !ok { + return PreflightResult{}, fmt.Errorf("check %q: %w", probe.Name, failure) + } + + switch { + case apiErr.Status == http.StatusBadRequest: + // The API rejected the empty body, so the scope to write is present. + case apiErr.Status == http.StatusNotFound && probe.FakeTarget: + // The resource does not exist, so the scope to change it is present. + case apiErr.Code == "feature_not_enabled": + return PreflightResult{}, &FeatureNotEnabledError{Tenant: tenantDomain} + default: + if gap, isGap := ParseScopeGap(probe.Name, failure); isGap { + result.Gaps = append(result.Gaps, gap) + continue + } + + result.Unexpected = append(result.Unexpected, UnexpectedProbe{ + Probe: probe.Name, + Status: apiErr.Status, + Message: apiErr.Message, + }) + } + } + + return result, nil +} + +// RequiredScopes returns the scopes the session must have, sorted. A gap where any one of several +// scopes would do is not listed: it is a choice. +func (r PreflightResult) RequiredScopes() []string { + var required []string + + for _, gap := range r.Gaps { + if gap.IsChoice() { + continue + } + + for _, scope := range gap.Scopes { + if !slices.Contains(required, scope) { + required = append(required, scope) + } + } + } + + slices.Sort(required) + + return required +} + +// Choices returns the gaps where any one of several scopes would do. +func (r PreflightResult) Choices() []ScopeGap { + var choices []ScopeGap + + for _, gap := range r.Gaps { + if gap.IsChoice() { + choices = append(choices, gap) + } + } + + return choices +} + +// Err returns the error that stops setup, or nil when the session has every permission. A refusal +// without a reason is its own error, because it usually means Universal Portals is not enabled. +func (r PreflightResult) Err(tenantDomain string) error { + var refused []string + + for _, unexpected := range r.Unexpected { + if unexpected.Status == http.StatusForbidden { + refused = append(refused, unexpected.Probe) + } + } + + if len(refused) > 0 { + return &PermissionCheckError{Probes: refused, Tenant: tenantDomain} + } + + required, choices := r.RequiredScopes(), r.Choices() + if len(required) > 0 || len(choices) > 0 { + return &MissingScopesError{Scopes: required, Choices: choices} + } + + return nil +} diff --git a/internal/portals/preflight_test.go b/internal/portals/preflight_test.go new file mode 100644 index 000000000..946d3c99d --- /dev/null +++ b/internal/portals/preflight_test.go @@ -0,0 +1,242 @@ +package portals_test + +import ( + "context" + "errors" + "net/http" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" +) + +// answeringProber answers each check the way the scope gate of the API would, keyed by "METHOD path". +// A check that is not listed succeeds. +type answeringProber struct { + answers map[string]error +} + +func (p answeringProber) Probe(_ context.Context, method, path string, _ any) error { + return p.answers[method+" "+path] +} + +func insufficient(scopes string, requirement string) error { + return &portals.APIError{ + Status: http.StatusForbidden, + Code: "insufficient_scope", + Message: "Insufficient scope, expected " + requirement + " of: " + scopes, + } +} + +var probesForTest = []portals.Probe{ + {Name: "create client", Method: http.MethodPost, Path: "clients", Body: map[string]any{}}, + {Name: "import forms", Method: http.MethodPost, Path: "forms/import", Body: map[string]any{}}, + {Name: "create portal", Method: http.MethodPost, Path: "portals", Body: map[string]any{}}, +} + +func TestRunPreflight(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + answers map[string]error + probes []portals.Probe + wantGaps []portals.ScopeGap + wantUnexpected []portals.UnexpectedProbe + }{ + {name: "a scope the session has is not reported"}, + { + name: "a validation error means the scope is present", + answers: map[string]error{"POST clients": &portals.APIError{Status: 400, Message: "Payload validation error"}}, + }, + { + name: "every missing scope is reported at once, with the requirement", + answers: map[string]error{ + "POST clients": insufficient("create:clients", "any"), + "POST forms/import": insufficient("create:forms,create:flows", "all"), + "POST portals": insufficient("create:portals", "any"), + }, + wantGaps: []portals.ScopeGap{ + {Probe: "create client", Requirement: portals.RequirementAny, Scopes: []string{"create:clients"}}, + {Probe: "import forms", Requirement: portals.RequirementAll, Scopes: []string{"create:forms", "create:flows"}}, + {Probe: "create portal", Requirement: portals.RequirementAny, Scopes: []string{"create:portals"}}, + }, + }, + { + name: "a 403 without a scope message is not a scope gap", + answers: map[string]error{"POST clients": &portals.APIError{Status: 403, Message: "Forbidden"}}, + wantUnexpected: []portals.UnexpectedProbe{ + {Probe: "create client", Status: 403, Message: "Forbidden"}, + }, + }, + { + name: "a 404 means the scope is present only for a probe on a resource that does not exist", + probes: []portals.Probe{ + {Name: "update client grant", Method: http.MethodPatch, Path: "client-grants/cgr_fake", FakeTarget: true}, + {Name: "list portals", Method: http.MethodGet, Path: "portals"}, + }, + answers: map[string]error{ + "PATCH client-grants/cgr_fake": &portals.APIError{Status: 404, Message: "The grant does not exist"}, + "GET portals": &portals.APIError{Status: 404, Message: "Not Found"}, + }, + wantUnexpected: []portals.UnexpectedProbe{ + {Probe: "list portals", Status: 404, Message: "Not Found"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + probes := probesForTest + if tt.probes != nil { + probes = tt.probes + } + + result, err := portals.RunPreflight(context.Background(), answeringProber{answers: tt.answers}, probes, "acme.us.auth0.com") + + require.NoError(t, err) + assert.Equal(t, tt.wantGaps, result.Gaps) + assert.Equal(t, tt.wantUnexpected, result.Unexpected) + }) + } +} + +func TestRunPreflight_FeatureNotEnabled(t *testing.T) { + t.Parallel() + + prober := answeringProber{answers: map[string]error{ + "POST portals": &portals.APIError{Status: 403, Code: "feature_not_enabled", Message: "Feature not enabled"}, + }} + + _, err := portals.RunPreflight(context.Background(), prober, probesForTest, "acme.us.auth0.com") + + var notEnabled *portals.FeatureNotEnabledError + require.ErrorAs(t, err, ¬Enabled) + assert.Equal(t, + "Universal Portals is not enabled for acme.us.auth0.com. Ask Auth0 to enable it for this tenant, then run the command again", + err.Error(), + ) +} + +func TestRunPreflight_AnErrorThatIsNotFromTheAPIStopsTheRun(t *testing.T) { + t.Parallel() + + failure := errors.New("connection refused") + prober := answeringProber{answers: map[string]error{"POST forms/import": failure}} + + _, err := portals.RunPreflight(context.Background(), prober, probesForTest, "acme.us.auth0.com") + + require.ErrorIs(t, err, failure) + assert.Contains(t, err.Error(), `check "import forms"`) +} + +// gatedProber holds back the first probe until the last one is answered, so the answers arrive in +// the reverse order of the probes. +type gatedProber struct { + once sync.Once + lastAnswerd chan struct{} +} + +func (p *gatedProber) Probe(_ context.Context, _, path string, _ any) error { + if path == "clients" { + <-p.lastAnswerd + + return insufficient("create:clients", "any") + } + + if path == "portals" { + defer p.once.Do(func() { close(p.lastAnswerd) }) + + return insufficient("create:portals", "any") + } + + return nil +} + +func TestRunPreflight_ReportsInTheOrderOfTheProbes(t *testing.T) { + t.Parallel() + + prober := &gatedProber{lastAnswerd: make(chan struct{})} + + result, err := portals.RunPreflight(context.Background(), prober, probesForTest, "acme.us.auth0.com") + + require.NoError(t, err) + require.Len(t, result.Gaps, 2) + assert.Equal(t, "create client", result.Gaps[0].Probe, "the first probe answered last, and is still reported first") + assert.Equal(t, "create portal", result.Gaps[1].Probe) +} + +func TestPreflightResult_Err(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result portals.PreflightResult + wantMessage string + wantFixes int + wantNoError bool + }{ + {name: "nothing is missing", wantNoError: true}, + { + name: "a check that failed for another reason does not stop setup", + result: portals.PreflightResult{ + Unexpected: []portals.UnexpectedProbe{{Probe: "list portals", Status: 404, Message: "Not Found"}}, + }, + wantNoError: true, + }, + { + name: "scopes that are all needed, sorted and without duplicates", + result: portals.PreflightResult{Gaps: []portals.ScopeGap{ + {Probe: "create portal", Requirement: portals.RequirementAny, Scopes: []string{"create:portals"}}, + {Probe: "import forms", Requirement: portals.RequirementAll, Scopes: []string{"create:forms", "create:flows"}}, + {Probe: "create form", Requirement: portals.RequirementAny, Scopes: []string{"create:forms"}}, + }}, + wantMessage: "The CLI session is missing 3 permission(s) setup needs: create:flows, create:forms, create:portals. Nothing was created", + wantFixes: 2, + }, + { + name: "a choice is listed as one of the scopes", + result: portals.PreflightResult{Gaps: []portals.ScopeGap{ + {Probe: "create client", Requirement: portals.RequirementAny, Scopes: []string{"create:clients"}}, + {Probe: "create portal", Requirement: portals.RequirementAny, Scopes: []string{"create:portals", "create:portals_beta"}}, + }}, + wantMessage: "The CLI session is missing 2 permission(s) setup needs: create:clients, one of create:portals or create:portals_beta. Nothing was created", + wantFixes: 2, + }, + { + name: "a refusal without a reason is its own error", + result: portals.PreflightResult{ + Unexpected: []portals.UnexpectedProbe{ + {Probe: "create portal", Status: 403, Message: "Forbidden"}, + {Probe: "import forms", Status: 403, Message: "Forbidden"}, + }, + Gaps: []portals.ScopeGap{{Requirement: portals.RequirementAny, Scopes: []string{"create:clients"}}}, + }, + wantMessage: `The Management API refused "create portal", "import forms" with HTTP 403 and gave no reason. Nothing was created. ` + + `Usually Universal Portals is not enabled for acme.us.auth0.com, or the session is missing a permission`, + wantFixes: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.result.Err("acme.us.auth0.com") + + if tt.wantNoError { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.Equal(t, tt.wantMessage, err.Error()) + assert.Len(t, portals.FixesOf(err), tt.wantFixes) + }) + } +} diff --git a/internal/portals/probes.go b/internal/portals/probes.go new file mode 100644 index 000000000..b8211a6eb --- /dev/null +++ b/internal/portals/probes.go @@ -0,0 +1,102 @@ +package portals + +import "net/http" + +// Probe is one request that checks a permission setup needs, without changing anything. A read +// that works, a write the API rejects with a 400, and, for a probe on a resource that does not +// exist, a 404, all mean the scope is present. A 403 names the scope that is missing. +type Probe struct { + Name string + Method string + Path string + Body any + // BusinessOnly marks a probe that only the business template needs. + BusinessOnly bool + // FakeTarget marks a probe on a resource that does not exist, so a 404 also means the scope is present. + FakeTarget bool +} + +// The IDs of the probes on a single resource are shaped like real ones, and nothing has them, so a +// probe can never change anything. +const ( + fakeClientID = "cli_preflight0000000" + fakeGrantID = "cgr_preflight0000000" + fakeVaultID = "ac_preflight0000000000000" + fakeOrganizationID = "org_preflight0000000" + fakeRoleID = "rol_preflight0000000" + fakeUserID = "auth0|preflight0000000000" +) + +// SetupProbes returns the checks setup runs before it creates anything, for the template. The +// create probes send an empty body, which the API rejects with a 400 for the missing properties. +func SetupProbes(template Template) []Probe { + probes := []Probe{ + {Name: "list resource servers", Method: http.MethodGet, Path: "resource-servers?per_page=1"}, + {Name: "create resource server", Method: http.MethodPost, Path: "resource-servers", Body: map[string]any{}}, + {Name: "list clients", Method: http.MethodGet, Path: "clients?per_page=1"}, + {Name: "create client", Method: http.MethodPost, Path: "clients", Body: map[string]any{}}, + {Name: "list client grants", Method: http.MethodGet, Path: "client-grants?per_page=1"}, + {Name: "create client grant", Method: http.MethodPost, Path: "client-grants", Body: map[string]any{}}, + // A rerun updates the application, the grants and the vault connection it finds. + {Name: "update client", Method: http.MethodPatch, Path: "clients/" + fakeClientID, Body: map[string]any{}, FakeTarget: true}, + {Name: "update client grant", Method: http.MethodPatch, Path: "client-grants/" + fakeGrantID, Body: map[string]any{}, FakeTarget: true}, + {Name: "list vault connections", Method: http.MethodGet, Path: "flows/vault/connections"}, + {Name: "create vault connection", Method: http.MethodPost, Path: "flows/vault/connections", Body: map[string]any{}}, + {Name: "update vault connection", Method: http.MethodPatch, Path: "flows/vault/connections/" + fakeVaultID, Body: map[string]any{}, FakeTarget: true}, + {Name: "list forms", Method: http.MethodGet, Path: "forms?per_page=1"}, + {Name: "import forms", Method: http.MethodPost, Path: "forms/import", Body: map[string]any{}}, + {Name: "list portals", Method: http.MethodGet, Path: "portals"}, + {Name: "create portal", Method: http.MethodPost, Path: "portals", Body: map[string]any{}}, + { + Name: "look up users by email", + Method: http.MethodGet, + Path: "users?q=email%3A%22preflight%40example.invalid%22&search_engine=v3", + BusinessOnly: true, + }, + {Name: "list organizations", Method: http.MethodGet, Path: "organizations?per_page=1", BusinessOnly: true}, + // Business setup can create an organization and a role, then link the user and the connection. + {Name: "create organization", Method: http.MethodPost, Path: "organizations", Body: map[string]any{}, BusinessOnly: true}, + { + Name: "enable organization connection", + Method: http.MethodPost, + Path: "organizations/" + fakeOrganizationID + "/enabled_connections", + Body: map[string]any{}, + BusinessOnly: true, + FakeTarget: true, + }, + { + Name: "add organization member", + Method: http.MethodPost, + Path: "organizations/" + fakeOrganizationID + "/members", + Body: map[string]any{}, + BusinessOnly: true, + FakeTarget: true, + }, + { + Name: "assign a role to a member", + Method: http.MethodPost, + Path: "organizations/" + fakeOrganizationID + "/members/" + fakeUserID + "/roles", + Body: map[string]any{}, + BusinessOnly: true, + FakeTarget: true, + }, + {Name: "create role", Method: http.MethodPost, Path: "roles", Body: map[string]any{}, BusinessOnly: true}, + { + Name: "add role permissions", + Method: http.MethodPost, + Path: "roles/" + fakeRoleID + "/permissions", + Body: map[string]any{}, + BusinessOnly: true, + FakeTarget: true, + }, + } + + selected := probes[:0:0] + for _, probe := range probes { + if !probe.BusinessOnly || template == TemplateBusiness { + selected = append(selected, probe) + } + } + + return selected +} diff --git a/internal/portals/probes_test.go b/internal/portals/probes_test.go new file mode 100644 index 000000000..fb24abc75 --- /dev/null +++ b/internal/portals/probes_test.go @@ -0,0 +1,153 @@ +package portals_test + +import ( + "context" + "net/http" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +func probeNames(probes []portals.Probe) []string { + names := make([]string, 0, len(probes)) + for _, probe := range probes { + names = append(names, probe.Name) + } + + return names +} + +func TestSetupProbes(t *testing.T) { + t.Parallel() + + consumer := probeNames(portals.SetupProbes(portals.TemplateConsumer)) + business := probeNames(portals.SetupProbes(portals.TemplateBusiness)) + + t.Run("the consumer template checks what a rerun updates too", func(t *testing.T) { + t.Parallel() + + assert.Contains(t, consumer, "update client") + assert.Contains(t, consumer, "update client grant") + assert.Contains(t, consumer, "update vault connection") + assert.NotContains(t, consumer, "create organization") + }) + + t.Run("the business template also checks what the test user needs", func(t *testing.T) { + t.Parallel() + + assert.Subset(t, business, consumer) + assert.Subset(t, business, []string{ + "look up users by email", "list organizations", "create organization", "enable organization connection", + "add organization member", "assign a role to a member", "create role", "add role permissions", + }) + }) + + t.Run("a probe never changes anything in the tenant", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + + result, err := portals.RunPreflight(context.Background(), tenant, portals.SetupProbes(portals.TemplateBusiness), "acme.us.auth0.com") + + require.NoError(t, err) + assert.Empty(t, result.Gaps) + assert.Empty(t, result.Unexpected, "every probe is answered the way the API answers it") + assert.Empty(t, tenant.Clients()) + assert.Empty(t, tenant.Portals()) + assert.Empty(t, tenant.Organizations()) + assert.Empty(t, tenant.Roles()) + }) + + t.Run("a scope that is denied is found by the probe that needs it", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.Deny("PATCH client-grants/cgr_preflight0000000", "Insufficient scope, expected any of: update:client_grants") + + result, err := portals.RunPreflight(context.Background(), tenant, portals.SetupProbes(portals.TemplateConsumer), "acme.us.auth0.com") + + require.NoError(t, err) + require.Len(t, result.Gaps, 1) + assert.Equal(t, "update client grant", result.Gaps[0].Probe) + assert.Equal(t, []string{"update:client_grants"}, result.RequiredScopes()) + }) +} + +// shapeOf is a request without its IDs, so a probe on a made-up resource and the real request on a +// resource that exists have the same shape. +func shapeOf(call string) string { + static := []string{ + "resource-servers", "clients", "client-grants", "flows", "vault", "connections", "forms", "import", + "portals", "custom-domains", "users", "organizations", "roles", "permissions", "members", "enabled_connections", + } + + method, path, _ := strings.Cut(call, " ") + segments := strings.Split(strings.SplitN(path, "?", 2)[0], "/") + + for index, segment := range segments { + if !slices.Contains(static, segment) { + segments[index] = "{id}" + } + } + + return method + " " + strings.Join(segments, "/") +} + +// The check of the permissions exists so that setup does not stop half way for a permission it could +// have found at the start, so each write setup makes needs a probe of its own shape. +func TestSetupProbes_CoverEveryWriteOfSetup(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tenant := portalstest.New() + seedDatabase(tenant) + seedUser(tenant, "auth0|a", "jane@acme.com") + + // A first run, and a rerun that updates the application it made. + runSetup(t, tenant, consumerOptions()) + runSetup(t, tenant, consumerOptions()) + + // A business run that creates an organization and a role, and gives the user the role. + deps, _, _ := interactiveDeps(tenant, answers{ + asks: []string{"Acme Admins", "acme-admins"}, + chooses: []string{"None of these"}, + confirms: []bool{true, true}, + }) + _, err := portals.Setup(ctx, businessOptions("jane@acme.com"), deps) + require.NoError(t, err) + + var probed []string + for _, probe := range portals.SetupProbes(portals.TemplateBusiness) { + probed = append(probed, shapeOf(probe.Method+" "+probe.Path)) + } + + var unprobed []string + + for _, call := range tenant.Calls() { + if strings.HasPrefix(call, http.MethodGet+" ") || strings.Contains(call, "preflight") { + continue + } + + if shape := shapeOf(call); !slices.Contains(probed, shape) && !slices.Contains(unprobed, shape) { + unprobed = append(unprobed, shape) + } + } + + assert.Empty(t, unprobed, "add a probe for each of these writes") +} + +func TestTemplate_IsValid(t *testing.T) { + t.Parallel() + + assert.True(t, portals.TemplateConsumer.IsValid()) + assert.True(t, portals.TemplateBusiness.IsValid()) + assert.False(t, portals.Template("").IsValid()) + assert.False(t, portals.Template("enterprise").IsValid()) +} diff --git a/internal/portals/resources.go b/internal/portals/resources.go new file mode 100644 index 000000000..96703b7be --- /dev/null +++ b/internal/portals/resources.go @@ -0,0 +1,384 @@ +package portals + +import ( + "context" + "fmt" + "net/http" + "slices" +) + +// Every ensure function looks for its resource by a stable key before it creates one, so rerunning +// setup after a failure reuses what already exists. + +// Status says what setup did with a resource. +type Status string + +const ( + // StatusCreated means the resource did not exist and was created. + StatusCreated Status = "created" + // StatusReused means the resource already existed and was left as it is. + StatusReused Status = "reused" + // StatusUpdated means the resource existed and was changed. + StatusUpdated Status = "updated" +) + +// describe is the status in what setup shows. +func (s Status) describe() string { + switch s { + case StatusReused: + return "already exists" + case StatusUpdated: + return "updated" + default: + return "created" + } +} + +// ensured is a resource and what was done with it. +type ensured[T any] struct { + value T + status Status +} + +// credentials are the ID and the secret of an application. +type credentials struct { + clientID string + clientSecret string +} + +func ensureResourceServer(ctx context.Context, store ResourceServerStore, spec ResourceServerSpec) (ensured[string], error) { + existing, err := findResourceServer(ctx, store, spec.Identifier) + if err != nil { + return ensured[string]{}, err + } + + if existing != nil { + return ensured[string]{value: existing.ID, status: StatusReused}, nil + } + + created, err := store.CreateResourceServer(ctx, spec) + if IsStatus(err, http.StatusConflict) { + // Another run created it between the read and the create. + if raced, findErr := findResourceServer(ctx, store, spec.Identifier); findErr == nil && raced != nil { + return ensured[string]{value: raced.ID, status: StatusReused}, nil + } + } + + if err != nil { + return ensured[string]{}, fmt.Errorf("create resource server %q: %w", spec.Identifier, err) + } + + return ensured[string]{value: created.ID, status: StatusCreated}, nil +} + +func findResourceServer(ctx context.Context, store ResourceServerStore, identifier string) (*ResourceServer, error) { + servers, err := store.ListResourceServers(ctx) + if err != nil { + return nil, fmt.Errorf("list resource servers: %w", err) + } + + for _, server := range servers { + if server.Identifier == identifier { + return &server, nil + } + } + + return nil, nil +} + +func findClientByName(ctx context.Context, store ClientStore, name string) (*Client, error) { + clients, err := store.ListClients(ctx) + if err != nil { + return nil, fmt.Errorf("list applications: %w", err) + } + + for _, client := range clients { + if client.Name == name { + return &client, nil + } + } + + return nil, nil +} + +// readClientSecret reads the secret of an application, which only the read of a single application +// returns, and only to a session with read:client_keys. +func readClientSecret(ctx context.Context, store ClientStore, clientID string) (string, error) { + secret, err := store.ReadClientSecret(ctx, clientID) + if err != nil { + return "", fmt.Errorf("read the secret of application %q: %w", clientID, err) + } + + if secret == "" { + return "", &ClientSecretUnreadableError{ClientID: clientID} + } + + return secret, nil +} + +func ensurePortalClient(ctx context.Context, store ClientStore, app portalApplication) (ensured[credentials], error) { + existing, err := findClientByName(ctx, store, app.name) + if err != nil { + return ensured[credentials]{}, err + } + + settings := portalClientSettings(app) + + if existing == nil { + created, createErr := store.CreateClient(ctx, settings) + if createErr != nil { + return ensured[credentials]{}, fmt.Errorf("create application %q: %w", app.name, createErr) + } + + return ensured[credentials]{ + value: credentials{clientID: created.ID, clientSecret: created.Secret}, + status: StatusCreated, + }, nil + } + + if existing.Metadata[portalSlugMetadata] != app.slug { + return ensured[credentials]{}, &NameInUseError{Name: app.name} + } + + update := ClientSettings{ + Callbacks: settings.Callbacks, + AllowedLogoutURLs: settings.AllowedLogoutURLs, + GrantTypes: settings.GrantTypes, + OrganizationUsage: settings.OrganizationUsage, + OrganizationRequireBehavior: settings.OrganizationRequireBehavior, + IPHeaderTrusted: settings.IPHeaderTrusted, + RefreshToken: settings.RefreshToken, + OIDCLogout: settings.OIDCLogout, + } + + // Session transfer is set only when it is missing, so a setting changed on purpose survives a rerun. + if existing.SessionTransfer == nil || len(existing.SessionTransfer.AllowedAuthenticationMethods) == 0 { + update.SessionTransfer = settings.SessionTransfer + } + + if err := store.UpdateClient(ctx, existing.ID, update); err != nil { + return ensured[credentials]{}, fmt.Errorf("update application %q: %w", app.name, err) + } + + secret, err := readClientSecret(ctx, store, existing.ID) + if err != nil { + return ensured[credentials]{}, err + } + + return ensured[credentials]{value: credentials{clientID: existing.ID, clientSecret: secret}, status: StatusReused}, nil +} + +func ensureFormsVaultClient(ctx context.Context, store ClientStore, name string) (ensured[credentials], error) { + existing, err := findClientByName(ctx, store, name) + if err != nil { + return ensured[credentials]{}, err + } + + if existing != nil { + if existing.Metadata[formsVaultMetadata] != formsVaultRole { + return ensured[credentials]{}, &NameInUseError{Name: name} + } + + secret, secretErr := readClientSecret(ctx, store, existing.ID) + if secretErr != nil { + return ensured[credentials]{}, secretErr + } + + return ensured[credentials]{value: credentials{clientID: existing.ID, clientSecret: secret}, status: StatusReused}, nil + } + + created, err := store.CreateClient(ctx, formsVaultClientSettings(name)) + if err != nil { + return ensured[credentials]{}, fmt.Errorf("create application %q: %w", name, err) + } + + return ensured[credentials]{ + value: credentials{clientID: created.ID, clientSecret: created.Secret}, + status: StatusCreated, + }, nil +} + +func ensureClientGrant(ctx context.Context, store ClientGrantStore, spec ClientGrantSpec) (ensured[string], error) { + grants, err := store.ListClientGrants(ctx, spec.ClientID) + if err != nil { + return ensured[string]{}, fmt.Errorf("list the grants of application %q: %w", spec.ClientID, err) + } + + index := slices.IndexFunc(grants, func(grant ClientGrant) bool { return grant.Audience == spec.Audience }) + if index < 0 { + created, createErr := store.CreateClientGrant(ctx, spec) + if createErr != nil { + return ensured[string]{}, fmt.Errorf("create the grant for %q: %w", spec.Audience, createErr) + } + + return ensured[string]{value: created.ID, status: StatusCreated}, nil + } + + existing := grants[index] + if sameScopes(existing.Scope, spec.Scope) { + return ensured[string]{value: existing.ID, status: StatusReused}, nil + } + + if err := store.UpdateClientGrantScope(ctx, existing.ID, spec.Scope); err != nil { + return ensured[string]{}, fmt.Errorf("update the grant for %q: %w", spec.Audience, err) + } + + return ensured[string]{value: existing.ID, status: StatusUpdated}, nil +} + +// sameScopes reports whether two lists hold the same scopes, whatever their order. +func sameScopes(left, right []string) bool { + normalize := func(scopes []string) []string { + sorted := slices.Clone(scopes) + slices.Sort(sorted) + + return slices.Compact(sorted) + } + + return slices.Equal(normalize(left), normalize(right)) +} + +func ensureVaultConnection(ctx context.Context, store VaultStore, name string, setup VaultSetup) (ensured[string], error) { + connections, err := store.ListVaultConnections(ctx) + if err != nil { + return ensured[string]{}, fmt.Errorf("list the vault connections: %w", err) + } + + for _, connection := range connections { + if connection.Name == name { + return ensured[string]{value: connection.ID, status: StatusReused}, nil + } + } + + created, err := store.CreateVaultConnection(ctx, name, setup) + if err != nil { + return ensured[string]{}, fmt.Errorf("create the vault connection %q: %w", name, err) + } + + return ensured[string]{value: created.ID, status: StatusCreated}, nil +} + +// refreshVaultConnection sends the setup of a vault connection again. An update without a setup +// keeps the old session, and sending it makes the vault sign in again with the grants in force. +func refreshVaultConnection(ctx context.Context, store VaultStore, id string, setup VaultSetup) error { + if err := store.UpdateVaultConnection(ctx, id, setup); err != nil { + return fmt.Errorf("refresh the vault connection %q: %w", id, err) + } + + return nil +} + +// formRef is a form in the tenant, with the flow it runs. +type formRef struct { + id string + flowID string +} + +// formOutcome is a form and what was done with it. +type formOutcome struct { + key FormKey + ref formRef + status Status +} + +func ensureForms(ctx context.Context, store FormStore, definitions []formDefinition) ([]formOutcome, error) { + existing, err := store.ListForms(ctx) + if err != nil { + return nil, fmt.Errorf("list the forms: %w", err) + } + + outcomes := make([]formOutcome, 0, len(definitions)) + + for _, definition := range definitions { + index := slices.IndexFunc(existing, func(form Form) bool { return form.Name == definition.name }) + if index >= 0 { + detail, readErr := store.ReadForm(ctx, existing[index].ID) + if readErr != nil { + return nil, fmt.Errorf("read the form %q: %w", definition.name, readErr) + } + + outcomes = append(outcomes, formOutcome{ + key: definition.key, + ref: formRef{id: existing[index].ID, flowID: detail.FlowID}, + status: StatusReused, + }) + + continue + } + + imported, importErr := store.ImportForm(ctx, definition.document) + if importErr != nil { + return nil, fmt.Errorf("import the form %q: %w", definition.name, importErr) + } + + outcomes = append(outcomes, formOutcome{ + key: definition.key, + ref: formRef{id: imported.ID, flowID: imported.FlowID}, + status: StatusCreated, + }) + } + + return outcomes, nil +} + +// findPortalBySlug returns the portal with the slug, read in full with its application, or nil when +// the slug is free. +func findPortalBySlug(ctx context.Context, store PortalStore, slug string) (*Portal, error) { + portalList, err := store.ListPortals(ctx) + if err != nil { + return nil, fmt.Errorf("list the portals: %w", err) + } + + index := slices.IndexFunc(portalList, func(portal PortalSummary) bool { return portal.Slug == slug }) + if index < 0 { + return nil, nil + } + + portal, err := store.ReadPortal(ctx, portalList[index].ID) + if err != nil { + return nil, fmt.Errorf("read portal %q: %w", slug, err) + } + + return &portal, nil +} + +func ensurePortal(ctx context.Context, store PortalStore, request CreatePortalRequest) (ensured[Portal], error) { + existing, err := findPortalBySlug(ctx, store, request.Slug) + if err != nil { + return ensured[Portal]{}, err + } + + if existing != nil { + if existing.Client == nil || existing.Client.ClientID != request.Client.ClientID { + return ensured[Portal]{}, &SlugTakenError{Slug: request.Slug} + } + + return ensured[Portal]{value: *existing, status: StatusReused}, nil + } + + created, err := store.CreatePortal(ctx, request) + if IsStatus(err, http.StatusConflict) { + return ensured[Portal]{}, &SlugTakenError{Slug: request.Slug} + } + + if err != nil { + return ensured[Portal]{}, fmt.Errorf("create the portal %q: %w", request.Slug, err) + } + + return ensured[Portal]{value: created, status: StatusCreated}, nil +} + +// portalRequest builds the request that creates a portal from its pages. +func portalRequest(template portalTemplate, slug, name string, app credentials) CreatePortalRequest { + return CreatePortalRequest{ + Slug: slug, + Name: name, + Client: &PortalClient{ + TokenEndpointAuthMethod: "client_secret_post", + ClientID: app.clientID, + ClientSecret: app.clientSecret, + }, + Navigation: &Navigation{Sidebar: &Sidebar{Components: template.Sidebar}}, + Pages: &Pages{Default: template.DefaultPage, Standard: template.Pages}, + } +} diff --git a/internal/portals/resources_test.go b/internal/portals/resources_test.go new file mode 100644 index 000000000..68346a959 --- /dev/null +++ b/internal/portals/resources_test.go @@ -0,0 +1,407 @@ +package portals_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +var businessApplication = portals.PortalApplicationInput{ + Name: "Acme Admin (Universal Portals)", + Slug: "acme-admin", + Template: portals.TemplateBusiness, + TenantDomain: tenantDomain, + Host: tenantDomain, +} + +func TestEnsureResourceServer(t *testing.T) { + t.Parallel() + + spec := portals.ResourceServerSpec{Name: "My Account", Identifier: "https://acme.us.auth0.com/me/"} + + t.Run("it is created once and reused on rerun", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + + first, err := portals.EnsureResourceServer(ctx, tenant, spec) + require.NoError(t, err) + second, err := portals.EnsureResourceServer(ctx, tenant, spec) + require.NoError(t, err) + + assert.Equal(t, portals.StatusCreated, first.Status) + assert.Equal(t, portals.StatusReused, second.Status) + assert.Equal(t, first.ID, second.ID) + assert.Len(t, tenant.ResourceServers(), 1) + }) + + t.Run("one created by another run in the meantime is reused", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.AddResourceServer(portals.ResourceServer{ID: "rs_other_run", Identifier: spec.Identifier}) + + // The first read does not see the resource server, which is how another run creates it between the read and the create. + outcome, err := portals.EnsureResourceServer(context.Background(), &blindOnce{ResourceServerStore: tenant}, spec) + + require.NoError(t, err) + assert.Equal(t, portals.StatusReused, outcome.Status) + assert.Equal(t, "rs_other_run", outcome.ID) + }) +} + +// blindOnce hides what a tenant has from the first list of resource servers. +type blindOnce struct { + portals.ResourceServerStore + listed bool +} + +func (b *blindOnce) ListResourceServers(ctx context.Context) ([]portals.ResourceServer, error) { + if !b.listed { + b.listed = true + return nil, nil + } + + return b.ResourceServerStore.ListResourceServers(ctx) +} + +func TestEnsurePortalClient(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("a rerun returns the same client and secret, without creating a second client", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + + first, err := portals.EnsurePortalClient(ctx, tenant, businessApplication) + require.NoError(t, err) + second, err := portals.EnsurePortalClient(ctx, tenant, businessApplication) + require.NoError(t, err) + + assert.Equal(t, portals.StatusCreated, first.Status) + assert.Equal(t, portals.StatusReused, second.Status) + assert.Equal(t, first.ClientID, second.ClientID) + assert.Equal(t, first.ClientSecret, second.ClientSecret) + assert.Len(t, tenant.Clients(), 1) + }) + + t.Run("a rerun turns on session transfer for an application that lacks it", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.AddClient(portals.Client{ID: "cli_1", Secret: "s", ClientSettings: portals.ClientSettings{ + Name: businessApplication.Name, + Metadata: map[string]string{"portals_setup_slug": businessApplication.Slug}, + }}) + + _, err := portals.EnsurePortalClient(ctx, tenant, businessApplication) + require.NoError(t, err) + + read, err := tenant.ReadClient(ctx, "cli_1") + require.NoError(t, err) + assert.Equal(t, &portals.SessionTransfer{AllowedAuthenticationMethods: []string{"query"}, EnforceDeviceBinding: "ip"}, read.SessionTransfer) + }) + + t.Run("a rerun keeps a session transfer setting that was changed on purpose", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + first, err := portals.EnsurePortalClient(ctx, tenant, businessApplication) + require.NoError(t, err) + require.NoError(t, tenant.UpdateClient(ctx, first.ClientID, portals.ClientSettings{ + SessionTransfer: &portals.SessionTransfer{AllowedAuthenticationMethods: []string{"query"}, EnforceDeviceBinding: "asn"}, + })) + + _, err = portals.EnsurePortalClient(ctx, tenant, businessApplication) + require.NoError(t, err) + + read, err := tenant.ReadClient(ctx, first.ClientID) + require.NoError(t, err) + assert.Equal(t, "asn", read.SessionTransfer.EnforceDeviceBinding) + }) + + t.Run("a rerun brings back what drifted, and leaves the rest", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + first, err := portals.EnsurePortalClient(ctx, tenant, businessApplication) + require.NoError(t, err) + require.NoError(t, tenant.UpdateClient(ctx, first.ClientID, portals.ClientSettings{ + Callbacks: []string{"https://elsewhere/callback"}, + GrantTypes: []string{"authorization_code", "client_credentials"}, + })) + + _, err = portals.EnsurePortalClient(ctx, tenant, businessApplication) + require.NoError(t, err) + + read, err := tenant.ReadClient(ctx, first.ClientID) + require.NoError(t, err) + assert.Equal(t, []string{"https://acme.us.auth0.com/portals/auth/callback"}, read.Callbacks) + assert.NotContains(t, read.GrantTypes, "client_credentials") + assert.Equal(t, "Acme Admin (Universal Portals)", read.Name) + }) + + t.Run("a same-named application that setup did not create is never reused", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.AddClient(portals.Client{ID: "cli_manual", Secret: "x", ClientSettings: portals.ClientSettings{Name: businessApplication.Name}}) + + _, err := portals.EnsurePortalClient(ctx, tenant, businessApplication) + + var inUse *portals.NameInUseError + require.ErrorAs(t, err, &inUse) + assert.Len(t, tenant.Clients(), 1) + }) + + t.Run("a same-named application made for another slug is never reused either", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.AddClient(portals.Client{ID: "cli_other", Secret: "x", ClientSettings: portals.ClientSettings{ + Name: businessApplication.Name, + Metadata: map[string]string{"portals_setup_slug": "another-slug"}, + }}) + + _, err := portals.EnsurePortalClient(ctx, tenant, businessApplication) + + var inUse *portals.NameInUseError + require.ErrorAs(t, err, &inUse) + }) + + t.Run("an application whose secret the session cannot read asks for read:client_keys", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.AddClient(portals.Client{ID: "cli_1", Secret: "", ClientSettings: portals.ClientSettings{ + Name: businessApplication.Name, + Metadata: map[string]string{"portals_setup_slug": businessApplication.Slug}, + }}) + + _, err := portals.EnsurePortalClient(ctx, tenant, businessApplication) + + var unreadable *portals.ClientSecretUnreadableError + require.ErrorAs(t, err, &unreadable) + assert.Contains(t, portals.FixesOf(err)[0], "read:client_keys") + }) +} + +func TestPortalClientSettings(t *testing.T) { + t.Parallel() + + t.Run("the application accepts session transfer tokens from native applications", func(t *testing.T) { + t.Parallel() + + settings := portals.PortalClientSettings(businessApplication) + + assert.Equal(t, &portals.SessionTransfer{AllowedAuthenticationMethods: []string{"query"}, EnforceDeviceBinding: "ip"}, settings.SessionTransfer) + }) + + t.Run("the application is a first-party web application that signs in with a secret in the body", func(t *testing.T) { + t.Parallel() + + settings := portals.PortalClientSettings(businessApplication) + + assert.Equal(t, "regular_web", settings.AppType) + assert.True(t, *settings.IsFirstParty) + assert.True(t, *settings.OIDCConformant) + assert.Equal(t, "client_secret_post", settings.TokenEndpointAuthMethod) + assert.Equal(t, "allow", settings.OrganizationUsage) + assert.Equal(t, "no_prompt", settings.OrganizationRequireBehavior) + assert.Equal(t, []string{"https://acme.us.auth0.com"}, settings.AllowedLogoutURLs) + assert.Nil(t, settings.OIDCLogout, "back-channel logout is only set on a custom domain") + }) + + t.Run("a refresh token policy for each API the template calls, with its scopes", func(t *testing.T) { + t.Parallel() + + settings := portals.PortalClientSettings(businessApplication) + + got := map[string][]string{} + for _, policy := range settings.RefreshToken.Policies { + got[policy.Audience] = policy.Scope + } + + assert.Equal(t, portals.AudiencesFor(portals.TemplateBusiness, tenantDomain), got) + assert.Equal(t, portals.MyOrganizationScopes(), got["https://acme.us.auth0.com/my-org/"]) + assert.Equal(t, portals.MyAccountScopes(), got["https://acme.us.auth0.com/me/"]) + }) +} + +func TestEnsureClientGrant(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + audience := "https://acme.us.auth0.com/me/" + + created, err := portals.EnsureClientGrant(ctx, tenant, portals.ClientGrantSpec{ClientID: "cli_1", Audience: audience, Scope: []string{"read:me:factors"}}) + require.NoError(t, err) + reused, err := portals.EnsureClientGrant(ctx, tenant, portals.ClientGrantSpec{ClientID: "cli_1", Audience: audience, Scope: []string{"read:me:factors"}}) + require.NoError(t, err) + reordered, err := portals.EnsureClientGrant(ctx, tenant, portals.ClientGrantSpec{ClientID: "cli_1", Audience: audience, Scope: []string{"read:me:factors", "read:me:factors"}}) + require.NoError(t, err) + updated, err := portals.EnsureClientGrant(ctx, tenant, portals.ClientGrantSpec{ + ClientID: "cli_1", + Audience: audience, + Scope: []string{"read:me:factors", "read:me:authentication_methods"}, + }) + require.NoError(t, err) + + assert.Equal(t, portals.StatusCreated, created.Status) + assert.Equal(t, portals.StatusReused, reused.Status) + assert.Equal(t, portals.StatusReused, reordered.Status, "the order and the repeats of the scopes do not matter") + assert.Equal(t, portals.StatusUpdated, updated.Status) + assert.Len(t, tenant.Grants(), 1) + assert.ElementsMatch(t, []string{"read:me:factors", "read:me:authentication_methods"}, tenant.Grants()[0].Scope) +} + +func TestVaultConnection(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + setup := portals.VaultSetup{Domain: tenantDomain, ClientID: "cli_vault", ClientSecret: "s"} + + created, err := portals.EnsureVaultConnection(ctx, tenant, "Universal Portals forms vault", setup) + require.NoError(t, err) + reused, err := portals.EnsureVaultConnection(ctx, tenant, "Universal Portals forms vault", setup) + require.NoError(t, err) + + assert.Equal(t, portals.StatusCreated, created.Status) + assert.Equal(t, portals.StatusReused, reused.Status) + assert.Equal(t, created.ID, reused.ID) + assert.Len(t, tenant.VaultConnections(), 1) + + require.NoError(t, portals.RefreshVaultConnection(ctx, tenant, created.ID, setup)) + assert.Contains(t, tenant.Writes(), "PATCH flows/vault/connections/"+created.ID, "a refresh sends the setup again") +} + +func TestEnsureForms(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("forms are imported once and reused by name", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + + first, err := portals.EnsureForms(ctx, tenant, "ac_vault") + require.NoError(t, err) + second, err := portals.EnsureForms(ctx, tenant, "ac_vault") + require.NoError(t, err) + + require.Len(t, first, 3) + assert.Equal(t, []portals.FormKey{portals.FormPersonalInfo, portals.FormCommunicationPreferences, portals.FormPrivacyConsent}, formKeys(first)) + + for index := range first { + assert.Equal(t, portals.StatusCreated, first[index].Status) + assert.Equal(t, portals.StatusReused, second[index].Status) + assert.Equal(t, first[index].ID, second[index].ID) + } + + assert.Len(t, tenant.Forms(), 3) + }) + + t.Run("each form reports the flow it runs, whether it was created or reused", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + + first, err := portals.EnsureForms(ctx, tenant, "ac_vault") + require.NoError(t, err) + second, err := portals.EnsureForms(ctx, tenant, "ac_vault") + require.NoError(t, err) + + assert.NotEmpty(t, first[0].FlowID, "a created form reports its flow") + assert.Equal(t, first[0].FlowID, second[0].FlowID) + }) + + t.Run("a failure leaves the forms that were imported, for the rerun to reuse", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + _, err := portals.EnsureForms(ctx, tenant, "ac_vault") + require.NoError(t, err) + tenant.DeleteForm(tenant.Forms()[2].ID) + tenant.FailNext("POST forms/import", http.StatusInternalServerError) + + _, err = portals.EnsureForms(ctx, tenant, "ac_vault") + + require.Error(t, err) + assert.Contains(t, err.Error(), "simulated failure") + assert.Len(t, tenant.Forms(), 2) + }) +} + +func formKeys(forms []portals.EnsuredForm) []portals.FormKey { + keys := make([]portals.FormKey, 0, len(forms)) + for _, form := range forms { + keys = append(keys, form.Key) + } + + return keys +} + +func TestEnsurePortal(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + forms, err := portals.EnsureForms(ctx, tenant, "ac_vault") + require.NoError(t, err) + + template, err := portals.TemplateFor(portals.TemplateConsumer, portals.FormIDs{ + portals.FormPersonalInfo: forms[0].ID, + portals.FormCommunicationPreferences: forms[1].ID, + portals.FormPrivacyConsent: forms[2].ID, + }) + require.NoError(t, err) + + created, err := portals.EnsurePortal(ctx, tenant, template, "acme-admin", "Acme Admin", "cli_1", "s") + require.NoError(t, err) + reused, err := portals.EnsurePortal(ctx, tenant, template, "acme-admin", "Acme Admin", "cli_1", "s") + require.NoError(t, err) + _, refused := portals.EnsurePortal(ctx, tenant, template, "acme-admin", "Acme Admin", "cli_other", "s") + + assert.Equal(t, portals.StatusCreated, created.Status) + assert.Equal(t, portals.StatusReused, reused.Status) + assert.Equal(t, created.ID, reused.ID) + + var taken *portals.SlugTakenError + require.ErrorAs(t, refused, &taken) + assert.Equal(t, `The portal slug "acme-admin" is already used by another portal`, refused.Error()) +} + +func TestPortalURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + organization string + want string + }{ + {name: "a consumer portal has no organization", want: "https://acme.us.auth0.com/portals/acme"}, + {name: "a business portal opens in an organization", organization: "org_1", want: "https://acme.us.auth0.com/portals/acme?organization=org_1"}, + {name: "an ID is escaped", organization: "org a/b", want: "https://acme.us.auth0.com/portals/acme?organization=org%20a%2Fb"}, + {name: "the placeholder stays readable", organization: "", want: "https://acme.us.auth0.com/portals/acme?organization="}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, portals.PortalURL(tenantDomain, "acme", tt.organization)) + }) + } +} diff --git a/internal/portals/scopes.go b/internal/portals/scopes.go new file mode 100644 index 000000000..466e76f57 --- /dev/null +++ b/internal/portals/scopes.go @@ -0,0 +1,143 @@ +package portals + +import ( + "errors" + "fmt" + "net/http" + "regexp" + "slices" + "strings" +) + +// requiredScopes are the Management API scopes the session of the tool needs. The advice for a user +// login asks for all of them, because `auth0 login --scopes` replaces the scopes of the last login. +var requiredScopes = []string{ + "read:clients", + "create:clients", + "update:clients", + "read:client_keys", + "read:client_grants", + "create:client_grants", + "update:client_grants", + "delete:client_grants", + "read:resource_servers", + "create:resource_servers", + "read:flows", + "create:flows", + "update:flows", + "read:flows_vault_connections", + "create:flows_vault_connections", + "update:flows_vault_connections", + "read:forms", + "create:forms", + "read:portals", + "create:portals", + "update:portals", + "read:custom_domains", + "read:users", + "read:organizations", + "create:organizations", + "read:organization_members", + "create:organization_members", + "create:organization_connections", + "read:connections", + "read:roles", + "create:roles", + "update:roles", +} + +// RequiredScopes returns the Management API scopes the session of the tool needs. +func RequiredScopes() []string { + return slices.Clone(requiredScopes) +} + +// ScopeFixes tells how to get the missing scopes, for each way the CLI can be signed in. A user +// login asks for every scope the tool uses and not only the missing ones, because the login command +// drops the scopes of the last login. A client-credentials login keeps its grant, so only the +// missing scopes are added to it. +func ScopeFixes(missing []string) []string { + login := slices.Clone(requiredScopes) + for _, scope := range missing { + if !slices.Contains(login, scope) { + login = append(login, scope) + } + } + + grant := fmt.Sprintf("Signed in with client credentials: add %s to the application's Management API grant", strings.Join(missing, ", ")) + if len(missing) == 0 { + grant = fmt.Sprintf( + "Signed in with client credentials: check that the application's Management API grant has %s", + strings.Join(requiredScopes, ", "), + ) + } + + return []string{ + fmt.Sprintf("Signed in as a user: auth0 login --scopes %s", strings.Join(login, ",")), + grant, + } +} + +// Requirement tells how many of the scopes of a gap are needed. +type Requirement int + +const ( + // RequirementUnknown is the zero value, which no gap has. + RequirementUnknown Requirement = iota + // RequirementAny means that one of the scopes is enough. + RequirementAny + // RequirementAll means that every one of the scopes is needed. + RequirementAll +) + +// ScopeGap is what a refused request says it needed. +type ScopeGap struct { + // Probe is the name of the check that was refused, empty for another request. + Probe string + Requirement Requirement + Scopes []string +} + +// IsChoice reports whether any one of several scopes would do, so none of them is required. +func (g ScopeGap) IsChoice() bool { + return g.Requirement == RequirementAny && len(g.Scopes) > 1 +} + +// Required returns the scopes to ask for: all of them, or the first when any one is enough. +func (g ScopeGap) Required() []string { + if g.Requirement == RequirementAny && len(g.Scopes) > 0 { + return g.Scopes[:1] + } + + return g.Scopes +} + +// scopeMessage matches the message of the Management API for a missing scope: +// "Insufficient scope, expected any of: a,b" or "Insufficient scope, expected all of: a,b". +var scopeMessage = regexp.MustCompile(`expected (any|all) of:\s*(.+)$`) + +// ParseScopeGap reads a refused request. It reports false for any other error. +func ParseScopeGap(probe string, err error) (ScopeGap, bool) { + apiErr, ok := errors.AsType[*APIError](err) + if !ok || apiErr.Status != http.StatusForbidden { + return ScopeGap{}, false + } + + match := scopeMessage.FindStringSubmatch(apiErr.Message) + if match == nil { + return ScopeGap{}, false + } + + requirement := RequirementAny + if match[1] == "all" { + requirement = RequirementAll + } + + var scopes []string + for _, scope := range strings.Split(match[2], ",") { + if scope = strings.TrimSpace(scope); scope != "" { + scopes = append(scopes, scope) + } + } + + return ScopeGap{Probe: probe, Requirement: requirement, Scopes: scopes}, true +} diff --git a/internal/portals/scopes_coverage_test.go b/internal/portals/scopes_coverage_test.go new file mode 100644 index 000000000..8becfd3a4 --- /dev/null +++ b/internal/portals/scopes_coverage_test.go @@ -0,0 +1,119 @@ +package portals_test + +import ( + "context" + "net/http" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +// scopeOf is the scope the Management API asks for a request: the verb comes from the HTTP method and +// the resource is the first segment of the path. +func scopeOf(call string) string { + method, path, _ := strings.Cut(call, " ") + + verbs := map[string]string{ + http.MethodGet: "read", + http.MethodPost: "create", + http.MethodPatch: "update", + http.MethodPut: "update", + http.MethodDelete: "delete", + } + + resource, _, _ := strings.Cut(path, "/") + + return verbs[method] + ":" + strings.ReplaceAll(resource, "-", "_") +} + +// Every request that setup, its checks, the audit and its fixes make needs a scope. If the list the +// advice asks for misses one, a person who followed the advice would still be refused. +func TestRequiredScopes_CoverEveryRequest(t *testing.T) { + t.Parallel() + + ctx := context.Background() + required := portals.RequiredScopes() + + t.Run("a consumer portal, its checks, and an audit that fixes it", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedDatabase(tenant) + result := runSetup(t, tenant, consumerOptions()) + tenant.EnableClientConnection(result.Application.ClientID, "con_db") + + for _, template := range []portals.Template{portals.TemplateConsumer, portals.TemplateBusiness} { + _, err := portals.RunPreflight(ctx, tenant, portals.SetupProbes(template), tenantDomain) + require.NoError(t, err) + } + + // Break the portal in the ways the fixes repair. + tenant.ModifyPortal(result.Portal.ID, func(p *portals.Portal) { + p.Pages.Default = "" + p.Navigation.Sidebar.Components = []portals.SidebarComponent{legacyLink("profile")} + }) + tenant.ModifyClient(result.Application.ClientID, func(client *portals.Client) { + client.RefreshToken.RotationType = "rotating" + client.RefreshToken.Policies = nil + client.GrantTypes = append(client.GrantTypes, "client_credentials") + client.Callbacks = nil + }) + tenant.AddGrant(portals.ClientGrant{ID: "cgr_mgmt", ClientID: result.Application.ClientID, Audience: "https://acme.us.auth0.com/api/v2/"}) + + _, err := portals.Audit(ctx, portals.AuditOptions{PortalID: result.Portal.ID, Fix: true}, portals.AuditDeps{ + API: tenant, Reporter: &portalstest.Reporter{}, Prompter: &portalstest.Prompter{}, TenantDomain: tenantDomain, + }) + require.NoError(t, err) + + assertScopesCover(t, required, tenant.Calls()) + }) + + t.Run("a business portal for a user who gets an organization and a role", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + seedDatabase(tenant) + seedUser(tenant, "auth0|a", "jane@acme.com") + deps, _, _ := interactiveDeps(tenant, answers{ + asks: []string{"Acme Admins", "acme-admins"}, + chooses: []string{"None of these"}, + confirms: []bool{true, true}, + }) + + _, err := portals.Setup(ctx, businessOptions("jane@acme.com"), deps) + require.NoError(t, err) + + assertScopesCover(t, required, tenant.Calls()) + assert.Contains(t, tenant.Calls(), "POST roles", "the role of the portal was created") + }) +} + +func assertScopesCover(t *testing.T, required, calls []string) { + t.Helper() + + var needed []string + + for _, call := range calls { + if scope := scopeOf(call); !slices.Contains(needed, scope) { + needed = append(needed, scope) + } + } + + slices.Sort(needed) + + var missing []string + + for _, scope := range needed { + if !slices.Contains(required, scope) { + missing = append(missing, scope) + } + } + + assert.Empty(t, missing, "add these scopes to the scopes the advice asks for") +} diff --git a/internal/portals/scopes_test.go b/internal/portals/scopes_test.go new file mode 100644 index 000000000..293b6de93 --- /dev/null +++ b/internal/portals/scopes_test.go @@ -0,0 +1,143 @@ +package portals_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/portals" +) + +func TestScopeFixes(t *testing.T) { + t.Parallel() + + const loginPrefix = "Signed in as a user: auth0 login --scopes " + + t.Run("a user login asks for every scope the tool uses, so it does not drop the ones granted before", func(t *testing.T) { + t.Parallel() + + login := portals.ScopeFixes([]string{"read:portals"})[0] + + assert.Equal(t, portals.RequiredScopes(), strings.Split(strings.TrimPrefix(login, loginPrefix), ",")) + }) + + t.Run("a scope outside the list of the tool is still asked for, once", func(t *testing.T) { + t.Parallel() + + login := portals.ScopeFixes([]string{"read:something_new", "read:something_new", "read:portals"})[0] + + assert.True(t, strings.HasSuffix(login, ",read:something_new"), login) + assert.Equal(t, 1, strings.Count(login, "read:something_new")) + assert.Equal(t, 1, strings.Count(login, "read:portals")) + }) + + t.Run("a client-credentials login only needs the missing scopes added to its grant", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, + "Signed in with client credentials: add read:portals, create:portals to the application's Management API grant", + portals.ScopeFixes([]string{"read:portals", "create:portals"})[1], + ) + }) + + t.Run("without a named scope the grant is checked against all of them", func(t *testing.T) { + t.Parallel() + + grant := portals.ScopeFixes(nil)[1] + + assert.True(t, strings.HasPrefix(grant, "Signed in with client credentials: check that the application's Management API grant has read:clients, "), grant) + assert.NotContains(t, grant, "add to") + }) +} + +func TestRequiredScopes_ReturnsACopy(t *testing.T) { + t.Parallel() + + scopes := portals.RequiredScopes() + scopes[0] = "changed" + + assert.NotEqual(t, "changed", portals.RequiredScopes()[0]) +} + +func TestParseScopeGap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want portals.ScopeGap + wantGap bool + }{ + { + name: "any of one scope", + err: &portals.APIError{Status: 403, Message: "Insufficient scope, expected any of: create:clients"}, + want: portals.ScopeGap{Probe: "create client", Requirement: portals.RequirementAny, Scopes: []string{"create:clients"}}, + wantGap: true, + }, + { + name: "all of several scopes, with spaces", + err: &portals.APIError{Status: 403, Message: "Insufficient scope, expected all of: create:forms, create:flows"}, + want: portals.ScopeGap{Probe: "create client", Requirement: portals.RequirementAll, Scopes: []string{"create:forms", "create:flows"}}, + wantGap: true, + }, + { + name: "the message with context added around the error", + err: wrapped(&portals.APIError{Status: 403, Message: "Insufficient scope, expected any of: read:portals"}), + want: portals.ScopeGap{Probe: "create client", Requirement: portals.RequirementAny, Scopes: []string{"read:portals"}}, + wantGap: true, + }, + {name: "another status", err: &portals.APIError{Status: 500, Message: "expected any of: create:clients"}}, + {name: "a 403 without a scope message", err: &portals.APIError{Status: 403, Message: "Forbidden"}}, + {name: "an error that is not from the API", err: assert.AnError}, + {name: "no error"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gap, isGap := portals.ParseScopeGap("create client", tt.err) + + assert.Equal(t, tt.wantGap, isGap) + assert.Equal(t, tt.want, gap) + }) + } +} + +func TestScopeGap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + gap portals.ScopeGap + wantChoice bool + wantRequired []string + }{ + { + name: "one scope is required", + gap: portals.ScopeGap{Requirement: portals.RequirementAny, Scopes: []string{"a"}}, + wantRequired: []string{"a"}, + }, + { + name: "any of several is a choice, and the first is the one to ask for", + gap: portals.ScopeGap{Requirement: portals.RequirementAny, Scopes: []string{"a", "b"}}, + wantChoice: true, + wantRequired: []string{"a"}, + }, + { + name: "all of several are required", + gap: portals.ScopeGap{Requirement: portals.RequirementAll, Scopes: []string{"a", "b"}}, + wantRequired: []string{"a", "b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.wantChoice, tt.gap.IsChoice()) + assert.Equal(t, tt.wantRequired, tt.gap.Required()) + }) + } +} diff --git a/internal/portals/setup.go b/internal/portals/setup.go new file mode 100644 index 000000000..2b2059315 --- /dev/null +++ b/internal/portals/setup.go @@ -0,0 +1,646 @@ +package portals + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" +) + +// SetupOptions are the choices that shape a portal. +type SetupOptions struct { + Template Template + Name string + Slug string + // UserQuery is the email or ID of the user who will test a business portal. + UserQuery string + // Organization is the ID or name of the organization of that user, when they have several. + Organization string +} + +// SetupDeps is what setup works with. +type SetupDeps struct { + API API + // Prober sends the permission checks. A dry run passes the real tenant here, because the checks + // change nothing. It defaults to API. + Prober Prober + Reporter Reporter + Prompter Prompter + // Interactive says whether there is somebody to ask. + Interactive bool + TenantDomain string +} + +func (d SetupDeps) prober() Prober { + if d.Prober != nil { + return d.Prober + } + + return d.API +} + +// SetupResult is what setup did. The order of the fields is the order of its JSON. +type SetupResult struct { + Template Template `json:"template"` + Portal PortalResult `json:"portal"` + Application ApplicationResult `json:"application"` + Vault VaultResult `json:"vault"` + Forms FormsResult `json:"forms"` + Resources []Resource `json:"resources"` + // Organization is set for a business portal whose test user was chosen. + Organization *OrganizationResult `json:"organization,omitempty"` +} + +// PortalResult is the portal. +type PortalResult struct { + ID string `json:"id"` + Slug string `json:"slug"` + URL string `json:"url"` + Status Status `json:"status"` +} + +// ApplicationResult is the application the portal signs users in with. +type ApplicationResult struct { + ClientID string `json:"clientId"` + Status Status `json:"status"` +} + +// VaultResult is the vault connection of the forms. +type VaultResult struct { + ID string `json:"id"` + Status Status `json:"status"` +} + +// FormResult is a form. +type FormResult struct { + ID string `json:"id"` + Status Status `json:"status"` +} + +// FormsResult are the forms of the portal. +type FormsResult struct { + PersonalInfo FormResult `json:"personalInfo"` + CommunicationPreferences FormResult `json:"communicationPreferences"` + PrivacyConsent FormResult `json:"privacyConsent"` +} + +// Resource is a resource setup created, reused or updated. +type Resource struct { + Type string `json:"type"` + ID string `json:"id"` + Status Status `json:"status"` +} + +// OrganizationResult is the organization a business portal opens in. +type OrganizationResult struct { + ID string `json:"id"` + Name string `json:"name"` + HasRole bool `json:"hasRole"` +} + +// Setup creates a portal, with the resources it needs, or finishes the setup of one that a failed +// run left half done. Every resource is looked for by a stable key before it is created, and the +// portal comes last, so rerunning the same command resumes. +func Setup(ctx context.Context, options SetupOptions, deps SetupDeps) (SetupResult, error) { + options, err := checkOptions(options, deps.Interactive) + if err != nil { + return SetupResult{}, err + } + + run := &setupRun{options: options, deps: deps, tenant: deps.TenantDomain} + + return run.execute(ctx) +} + +// checkOptions tells what is wrong in the options before anything is sent, even the check of the +// permissions, and returns them with the slug that was left empty made from the name. +func checkOptions(options SetupOptions, interactive bool) (SetupOptions, error) { + if !options.Template.IsValid() { + return options, &UsageError{ + Message: fmt.Sprintf("unknown template %q: use consumer or business", options.Template), + Hints: []string{"Use --template consumer or --template business."}, + } + } + + if strings.TrimSpace(options.Name) == "" { + return options, &UsageError{ + Message: "A portal needs a name", + Hints: []string{"Use --name with the name of the portal."}, + } + } + + if options.Slug == "" { + options.Slug = SlugFromName(options.Name) + } + + if !IsValidSlug(options.Slug) { + return options, &UsageError{ + Message: fmt.Sprintf("The slug %q must match %s", options.Slug, SlugPattern), + Hints: []string{"Use --slug with lowercase letters, digits and hyphens, starting with a letter."}, + } + } + + // A run that cannot ask has to be told the user up front. + if options.Template == TemplateBusiness && options.UserQuery == "" && !interactive { + return options, &BusinessAccessError{ + Message: "A Business portal needs the user who will test it", + Hints: []string{"Rerun with --user ."}, + } + } + + return options, nil +} + +// setupRun is one run of Setup. +type setupRun struct { + options SetupOptions + deps SetupDeps + tenant string + resources []Resource +} + +func (r *setupRun) report() Reporter { return r.deps.Reporter } + +func (r *setupRun) record(kind, id string, status Status) { + r.resources = append(r.resources, Resource{Type: kind, ID: id, Status: status}) +} + +// task runs work behind a spinner and returns what it made. +func task[T any](reporter Reporter, title string, work func() (T, error)) (T, error) { + var value T + + err := reporter.Task(title, func() error { + var workErr error + value, workErr = work() + + return workErr + }) + + return value, err +} + +func (r *setupRun) execute(ctx context.Context) (SetupResult, error) { + options := r.options + + if err := r.checkPermissions(ctx); err != nil { + return SetupResult{}, err + } + + appName := portalName(options.Name) + + slug, err := r.availableSlug(ctx, options.Slug, appName) + if err != nil { + return SetupResult{}, err + } + + // The test user, or the organization of the link, is settled before anything is created. + var link testLink + + if options.Template == TemplateBusiness { + if link, err = r.chooseTestLink(ctx); err != nil { + return SetupResult{}, err + } + } + + host, err := resolvePortalHost(ctx, r.deps.API, r.tenant) + if err != nil { + return SetupResult{}, err + } + + if err := r.setUpResourceServers(ctx); err != nil { + return SetupResult{}, err + } + + client, err := r.setUpApplication(ctx, appName, slug, host) + if err != nil { + return SetupResult{}, err + } + + vault, forms, err := r.setUpForms(ctx) + if err != nil { + return SetupResult{}, err + } + + portal, err := r.setUpPortal(ctx, slug, client, forms) + if err != nil { + return SetupResult{}, err + } + + if link.isPlaceholder { + r.report().Info(fmt.Sprintf("Replace %s in the link with the organization ID to test with.", organizationPlaceholder)) + } + + return SetupResult{ + Template: options.Template, + Portal: PortalResult{ + ID: portal.value.ID, + Slug: portal.value.Slug, + URL: portalURL(host.domain, portal.value.Slug, link.organizationID), + Status: portal.status, + }, + Application: ApplicationResult{ClientID: client.value.clientID, Status: client.status}, + Vault: VaultResult{ID: vault.value, Status: vault.status}, + Forms: FormsResult{ + PersonalInfo: formResult(forms[FormPersonalInfo]), + CommunicationPreferences: formResult(forms[FormCommunicationPreferences]), + PrivacyConsent: formResult(forms[FormPrivacyConsent]), + }, + Resources: r.resources, + Organization: link.result(), + }, nil +} + +func formResult(outcome formOutcome) FormResult { + return FormResult{ID: outcome.ref.id, Status: outcome.status} +} + +// checkPermissions sends the checks before anything is created, so a missing permission is reported +// once and nothing is left half done. +func (r *setupRun) checkPermissions(ctx context.Context) error { + r.report().Section("Permissions") + + result, err := task(r.report(), "Checking permissions", func() (PreflightResult, error) { + return RunPreflight(ctx, r.deps.prober(), SetupProbes(r.options.Template), r.tenant) + }) + if err != nil { + return err + } + + // The error lists the scopes and the fixes once, so they are not shown here as well. + if err := result.Err(r.tenant); err != nil { + return err + } + + for _, issue := range result.Unexpected { + detail := "" + if !strings.Contains(issue.Message, fmt.Sprint(issue.Status)) { + detail = fmt.Sprintf(" (HTTP %d)", issue.Status) + } + + r.report().Warn(fmt.Sprintf("Could not verify %q: %s%s", issue.Probe, issue.Message, detail)) + } + + if len(result.Unexpected) > 0 { + r.report().Result("No permission is missing, but the checks above could not be verified") + } else { + r.report().Result("The CLI session has every permission setup needs") + } + + return nil +} + +// availableSlug runs before anything is created: a slug used by another portal is fixed here and +// not after the application and the forms exist. A portal that already uses the application of this +// setup is a rerun, so it passes. +func (r *setupRun) availableSlug(ctx context.Context, slug, appName string) (string, error) { + current := slug + + for { + portal, err := findPortalBySlug(ctx, r.deps.API, current) + if err != nil { + return "", err + } + + if portal == nil { + return current, nil + } + + app, err := findClientByName(ctx, r.deps.API, appName) + if err != nil { + return "", err + } + + if app != nil && portal.Client != nil && portal.Client.ClientID == app.ID { + return current, nil + } + + if !r.deps.Interactive { + return "", &SlugTakenError{Slug: current} + } + + r.report().Warn(fmt.Sprintf("The slug %q is already taken.", current)) + + current, err = r.askValidSlug(current + "-2") + if err != nil { + return "", err + } + } +} + +func (r *setupRun) askValidSlug(suggestion string) (string, error) { + for { + answer, err := r.deps.Prompter.Ask("Slug", suggestion, "") + if err != nil { + return "", err + } + + if IsValidSlug(answer) { + return answer, nil + } + + r.report().Error("The slug must start with a lowercase letter and use only lowercase letters, digits and hyphens.") + } +} + +func (r *setupRun) setUpResourceServers(ctx context.Context) error { + r.report().Section("Resource servers") + + for _, server := range resourceServersFor(r.options.Template, r.tenant) { + outcome, err := task(r.report(), "Checking "+server.Name, func() (ensured[string], error) { + return ensureResourceServer(ctx, r.deps.API, server) + }) + if err != nil { + return err + } + + r.record("resource_server", outcome.value, outcome.status) + r.report().Item(fmt.Sprintf("%q", server.Name), outcome.status.describe()) + } + + r.report().Result("Resource servers ready") + + return nil +} + +func resourceServersFor(template Template, tenantDomain string) []ResourceServerSpec { + account := ResourceServerSpec{ + Name: "Auth0 My Account API", + Identifier: myAccountAudience(tenantDomain), + SkipConsentForVerifiableFirstPartyClients: true, + TokenDialect: "rfc9068_profile", + } + if template == TemplateConsumer { + return []ResourceServerSpec{account} + } + + organization := ResourceServerSpec{ + Name: "Auth0 My Organization API", + Identifier: myOrganizationAudience(tenantDomain), + SkipConsentForVerifiableFirstPartyClients: true, + TokenDialect: "rfc9068_profile", + } + + return []ResourceServerSpec{organization, account} +} + +func (r *setupRun) setUpApplication(ctx context.Context, appName, slug string, host portalHost) (ensured[credentials], error) { + r.report().Section("Application") + + client, err := task(r.report(), "Creating application", func() (ensured[credentials], error) { + return ensurePortalClient(ctx, r.deps.API, portalApplication{ + name: appName, + slug: slug, + template: r.options.Template, + tenantDomain: r.tenant, + host: host.domain, + isCustomDomain: host.isCustom, + }) + }) + if err != nil { + return ensured[credentials]{}, err + } + + r.record("application", client.value.clientID, client.status) + r.report().Item(fmt.Sprintf("%q", appName), client.status.describe()) + r.report().Reference("Application", client.value.clientID, ApplicationURL(r.tenant, client.value.clientID)) + + for _, access := range audiencesFor(r.options.Template, r.tenant) { + label := apiLabel(access.audience, r.tenant) + + grant, grantErr := task(r.report(), "Granting "+label, func() (ensured[string], error) { + return ensureClientGrant(ctx, r.deps.API, ClientGrantSpec{ + ClientID: client.value.clientID, + Audience: access.audience, + Scope: access.scope, + SubjectType: "user", + }) + }) + if grantErr != nil { + return ensured[credentials]{}, grantErr + } + + r.record("client_grant", grant.value, grant.status) + r.report().Item(fmt.Sprintf("%q access", label), grant.status.describe()) + } + + r.report().Result("Application ready") + + return client, nil +} + +// setUpForms prepares the forms vault, then creates the forms that write through it. +func (r *setupRun) setUpForms(ctx context.Context) (ensured[string], map[FormKey]formOutcome, error) { + r.report().Section("Forms") + + vault, err := r.setUpFormsVault(ctx) + if err != nil { + return ensured[string]{}, nil, err + } + + definitions, err := buildForms(vault.value) + if err != nil { + return ensured[string]{}, nil, err + } + + outcomes, err := task(r.report(), "Creating forms", func() ([]formOutcome, error) { + return ensureForms(ctx, r.deps.API, definitions) + }) + if err != nil { + return ensured[string]{}, nil, err + } + + forms := make(map[FormKey]formOutcome, len(outcomes)) + + for _, outcome := range outcomes { + forms[outcome.key] = outcome + + r.record("form", outcome.ref.id, outcome.status) + r.report().Item(fmt.Sprintf("%q", FormName(outcome.key)), outcome.status.describe()) + r.report().Reference("Form", outcome.ref.id, FormEditURL(r.tenant, outcome.ref.id)) + + if outcome.ref.flowID != "" { + r.report().Reference("Flow", outcome.ref.flowID, FlowEditURL(r.tenant, outcome.ref.flowID)) + } + } + + r.report().Result("Forms ready") + + return vault, forms, nil +} + +// setUpFormsVault prepares the vault shared by the forms: one machine-to-machine application and +// one vault connection for the tenant. A change of its scopes needs a refresh, which sends the setup +// again so the vault signs in with the new grants. +func (r *setupRun) setUpFormsVault(ctx context.Context) (ensured[string], error) { + app, err := task(r.report(), "Preparing the forms vault", func() (ensured[credentials], error) { + return ensureFormsVaultClient(ctx, r.deps.API, formsVaultName) + }) + if err != nil { + return ensured[string]{}, err + } + + grant, err := ensureClientGrant(ctx, r.deps.API, ClientGrantSpec{ + ClientID: app.value.clientID, + Audience: managementAudience(r.tenant), + Scope: formsVaultScopes, + SubjectType: "client", + }) + if err != nil { + return ensured[string]{}, err + } + + setup := VaultSetup{Domain: r.tenant, ClientID: app.value.clientID, ClientSecret: app.value.clientSecret} + + vault, err := ensureVaultConnection(ctx, r.deps.API, formsVaultName, setup) + if err != nil { + return ensured[string]{}, err + } + + if grant.status == StatusUpdated && vault.status == StatusReused { + if err := refreshVaultConnection(ctx, r.deps.API, vault.value, setup); err != nil { + return ensured[string]{}, err + } + + r.report().Item("Forms vault refreshed with the updated permissions", "") + } + + r.record("forms_vault_client", app.value.clientID, app.status) + r.record("forms_vault_grant", grant.value, grant.status) + r.record("forms_vault", vault.value, vault.status) + r.report().Item(fmt.Sprintf("%q", formsVaultName), vault.status.describe()) + // Vault connections have no page of their own, so the ID links to the AUTH0 app type. + r.report().Reference("Vault connection", vault.value, VaultAppsURL(r.tenant)) + + return vault, nil +} + +func (r *setupRun) setUpPortal( + ctx context.Context, + slug string, + client ensured[credentials], + forms map[FormKey]formOutcome, +) (ensured[Portal], error) { + template, err := templateFor(r.options.Template, formIDs{ + FormPersonalInfo: forms[FormPersonalInfo].ref.id, + FormPrivacyConsent: forms[FormPrivacyConsent].ref.id, + FormCommunicationPreferences: forms[FormCommunicationPreferences].ref.id, + }) + if err != nil { + return ensured[Portal]{}, err + } + + r.report().Section("Portal") + + portal, err := task(r.report(), "Creating portal", func() (ensured[Portal], error) { + return ensurePortal(ctx, r.deps.API, portalRequest(template, slug, r.options.Name, client.value)) + }) + if _, taken := asSlugTaken(err); taken { + // No prompt can run inside the task, so the run stops. The resources created so far stay, and + // a new name gives a new application, so the rerun needs both a new name and a new slug. + return ensured[Portal]{}, &SlugRaceError{Slug: slug} + } + + if err != nil { + return ensured[Portal]{}, err + } + + r.record("portal", portal.value.ID, portal.status) + r.report().Item(fmt.Sprintf("%q", r.options.Name), portal.status.describe()) + r.report().Result("Portal ready") + r.report().Reference("Portal", portal.value.ID, PortalEditURL(r.tenant, portal.value.ID)) + + return portal, nil +} + +// portalHost is where a portal is served from. +type portalHost struct { + domain string + isCustom bool +} + +// resolvePortalHost returns the custom domain of the tenant when it is ready, and the tenant domain +// otherwise. +func resolvePortalHost(ctx context.Context, store CustomDomainStore, tenantDomain string) (portalHost, error) { + custom, err := store.ReadDefaultCustomDomain(ctx) + if IsStatus(err, http.StatusNotFound) { + return portalHost{domain: tenantDomain}, nil + } + + if err != nil { + return portalHost{}, fmt.Errorf("read the custom domain: %w", err) + } + + if custom.IsReady() { + return portalHost{domain: custom.Domain, isCustom: true}, nil + } + + return portalHost{domain: tenantDomain}, nil +} + +// organizationPlaceholder stays in the link of a business portal until the person replaces it with +// an organization ID. +const organizationPlaceholder = "" + +// portalURL is the address of a portal. A business portal opens in an organization. +func portalURL(host, slug, organizationID string) string { + address := "https://" + host + "/portals/" + slug + if organizationID == "" { + return address + } + + // The placeholder stays readable, so it is easy to find and replace. + value := organizationID + if organizationID != organizationPlaceholder { + value = strings.ReplaceAll(url.QueryEscape(organizationID), "+", "%20") + } + + return address + "?organization=" + value +} + +// AskForMissing asks for the choices that were not given, before anything is checked or created: +// the kind of portal, its name, and its slug. The slug defaults to the one made from the name. +func AskForMissing(options SetupOptions, prompter Prompter) (SetupOptions, error) { + if options.Template == "" { + choices := []Choice{ + {Label: "Consumer portal", Hint: "for end-users to manage their account in a self-service way"}, + {Label: "Business portal", Hint: "for organization administrators to manage their organization in a self-service way"}, + } + + picked, err := prompter.Choose("Which kind of portal do you want to set up?", choices) + if err != nil { + return SetupOptions{}, err + } + + options.Template = []Template{TemplateConsumer, TemplateBusiness}[picked] + } + + if options.Name == "" { + name, err := prompter.Ask("Portal name", "My Portal", "") + if err != nil { + return SetupOptions{}, err + } + + options.Name = name + } + + if options.Slug == "" { + slug, err := prompter.Ask("URL slug", SlugFromName(options.Name), "") + if err != nil { + return SetupOptions{}, err + } + + options.Slug = slug + } + + return options, nil +} + +// result is the organization of the result of setup, set when a test user was chosen. +func (l testLink) result() *OrganizationResult { + if l.access == nil { + return nil + } + + return &OrganizationResult{ID: l.access.organizationID, Name: l.access.organizationName, HasRole: l.access.hasOrganizationRole} +} diff --git a/internal/portals/setup_test.go b/internal/portals/setup_test.go new file mode 100644 index 000000000..7fea19f77 --- /dev/null +++ b/internal/portals/setup_test.go @@ -0,0 +1,616 @@ +package portals_test + +import ( + "context" + "encoding/json" + "net/http" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/portals" + "github.com/auth0/auth0-cli/internal/portals/portalstest" +) + +const tenantDomain = "acme.us.auth0.com" + +func consumerOptions() portals.SetupOptions { + return portals.SetupOptions{Template: portals.TemplateConsumer, Name: "Acme Account", Slug: "acme-account"} +} + +// newSetupDeps returns what setup needs to run on a tenant without a terminal: any question fails +// the test, unless the test says how to answer it. +func newSetupDeps(tenant *portalstest.Tenant) (portals.SetupDeps, *portalstest.Reporter, *portalstest.Prompter) { + reporter := &portalstest.Reporter{} + prompter := &portalstest.Prompter{} + + return portals.SetupDeps{ + API: tenant, + Reporter: reporter, + Prompter: prompter, + TenantDomain: tenantDomain, + }, reporter, prompter +} + +// clientNamed returns the application whose name starts with the prefix. +func clientNamed(t *testing.T, tenant *portalstest.Tenant, prefix string) portals.Client { + t.Helper() + + for _, client := range tenant.Clients() { + if strings.HasPrefix(client.Name, prefix) { + return client + } + } + + require.FailNow(t, "no application starts with "+prefix) + + return portals.Client{} +} + +func runSetup(t *testing.T, tenant *portalstest.Tenant, options portals.SetupOptions) portals.SetupResult { + t.Helper() + + deps, _, _ := newSetupDeps(tenant) + result, err := portals.Setup(context.Background(), options, deps) + require.NoError(t, err) + + return result +} + +func TestSetup_ConsumerPortalEndToEnd(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, reporter, _ := newSetupDeps(tenant) + + result, err := portals.Setup(context.Background(), consumerOptions(), deps) + + require.NoError(t, err) + assert.Equal(t, portals.StatusCreated, result.Portal.Status) + assert.Equal(t, "https://acme.us.auth0.com/portals/acme-account", result.Portal.URL) + assert.Len(t, tenant.Portals(), 1) + assert.Len(t, tenant.Forms(), 3) + assert.Len(t, tenant.VaultConnections(), 1) + + secret := clientNamed(t, tenant, "Acme Account").Secret + require.NotEmpty(t, secret) + + for _, line := range reporter.Lines() { + assert.NotContains(t, line, secret, "the client secret must never be shown") + } +} + +func TestSetup_TheResultIsTheJSONTheToolPrinted(t *testing.T) { + t.Parallel() + + result := runSetup(t, portalstest.New(), consumerOptions()) + + got, err := json.MarshalIndent(result, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + want, err := os.ReadFile("testdata/consumer-setup.json") + require.NoError(t, err) + assert.Equal(t, string(want), string(got)) +} + +func TestSetup_ShowsTheStepsInOrder(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, reporter, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + require.NoError(t, err) + assert.Equal(t, []string{ + "section: Permissions", + "task: Checking permissions", + "result: The CLI session has every permission setup needs", + "section: Resource servers", + "task: Checking Auth0 My Account API", + `item: "Auth0 My Account API" | created`, + "result: Resource servers ready", + "section: Application", + "task: Creating application", + `item: "Acme Account (Universal Portals)" | created`, + "reference: Application | 00000000000000000000000000000003 | https://manage.auth0.com/dashboard/us/acme/applications/00000000000000000000000000000003/settings", + "task: Granting My Account API", + `item: "My Account API" access | created`, + "result: Application ready", + "section: Forms", + "task: Preparing the forms vault", + `item: "Universal Portals forms vault" | created`, + "reference: Vault connection | ac_0000000000000000000007 | https://forms.auth0.com/tenants/us/acme/vault/apps/AUTH0/edit", + "task: Creating forms", + `item: "Profile update (Universal Portals)" | created`, + "reference: Form | ap_0000000000000000000008 | https://forms.auth0.com/tenants/us/acme/forms/ap_0000000000000000000008/edit", + "reference: Flow | af_0000000000000000000009 | https://forms.auth0.com/tenants/us/acme/flows/af_0000000000000000000009/edit", + `item: "Marketing communication preferences (Universal Portals)" | created`, + "reference: Form | ap_0000000000000000000010 | https://forms.auth0.com/tenants/us/acme/forms/ap_0000000000000000000010/edit", + "reference: Flow | af_0000000000000000000011 | https://forms.auth0.com/tenants/us/acme/flows/af_0000000000000000000011/edit", + `item: "Privacy settings (Universal Portals)" | created`, + "reference: Form | ap_0000000000000000000012 | https://forms.auth0.com/tenants/us/acme/forms/ap_0000000000000000000012/edit", + "reference: Flow | af_0000000000000000000013 | https://forms.auth0.com/tenants/us/acme/flows/af_0000000000000000000013/edit", + "result: Forms ready", + "section: Portal", + "task: Creating portal", + `item: "Acme Account" | created`, + "result: Portal ready", + "reference: Portal | up_0000000000000000000014 | https://manage.auth0.com/dashboard/us/acme/universal-portals/up_0000000000000000000014/edit", + }, reporter.Lines()) +} + +func TestSetup_TheApplicationOfAConsumerPortal(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + runSetup(t, tenant, consumerOptions()) + + client := clientNamed(t, tenant, "Acme Account") + + assert.NotContains(t, client.GrantTypes, "client_credentials", "the portal application must not have client_credentials") + assert.Equal(t, "non-rotating", client.RefreshToken.RotationType) + assert.Equal(t, 3600, *client.RefreshToken.IdleTokenLifetime) + assert.Equal(t, 86400, *client.RefreshToken.TokenLifetime) + + audiences := make([]string, 0) + for _, policy := range client.RefreshToken.Policies { + audiences = append(audiences, policy.Audience) + } + + assert.Equal(t, []string{"https://acme.us.auth0.com/me/"}, audiences, "only the My Account policy") + assert.Equal(t, map[string]string{"portals_setup_slug": "acme-account"}, client.Metadata) + assert.Equal(t, []string{"query"}, client.SessionTransfer.AllowedAuthenticationMethods) + assert.Equal(t, "ip", client.SessionTransfer.EnforceDeviceBinding) +} + +func TestSetup_ARerunReusesEveryResourceAndCreatesNothing(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + runSetup(t, tenant, consumerOptions()) + + counts := func() []int { + return []int{len(tenant.Clients()), len(tenant.Grants()), len(tenant.Forms()), len(tenant.Portals()), len(tenant.VaultConnections())} + } + before := counts() + + rerun := runSetup(t, tenant, consumerOptions()) + + assert.Equal(t, portals.StatusReused, rerun.Portal.Status) + assert.Equal(t, portals.StatusReused, rerun.Application.Status) + assert.Equal(t, portals.StatusReused, rerun.Vault.Status) + assert.Equal(t, portals.StatusReused, rerun.Forms.PersonalInfo.Status) + assert.Equal(t, portals.StatusReused, rerun.Forms.CommunicationPreferences.Status) + assert.Equal(t, portals.StatusReused, rerun.Forms.PrivacyConsent.Status) + assert.Equal(t, before, counts()) +} + +func TestSetup_AFailurePartwayIsRecoveredByRerunningTheSameCommand(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.FailNext("POST forms/import", http.StatusInternalServerError) + deps, _, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + require.Error(t, err) + assert.Contains(t, err.Error(), "simulated failure") + assert.Empty(t, tenant.Portals(), "the portal is the last step, so it must not exist yet") + + rerun := runSetup(t, tenant, consumerOptions()) + + assert.Equal(t, portals.StatusCreated, rerun.Portal.Status) + assert.Equal(t, portals.StatusReused, rerun.Application.Status) + assert.Len(t, tenant.Clients(), 2, "one portal application and one forms vault application") +} + +func TestSetup_ATakenSlug(t *testing.T) { + t.Parallel() + + takenByAnother := func(tenant *portalstest.Tenant) { + tenant.AddPortal(portals.Portal{ID: "up_other", Slug: "acme-account", Name: "Other", Client: &portals.PortalClient{ClientID: "cli_x"}}) + } + + t.Run("fails without a prompt when there is no terminal, before anything is created", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + takenByAnother(tenant) + deps, _, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + var taken *portals.SlugTakenError + require.ErrorAs(t, err, &taken) + assert.Equal(t, "acme-account", taken.Slug) + assert.Contains(t, err.Error(), "already used by another portal") + assert.Empty(t, tenant.Clients()) + assert.Empty(t, tenant.Grants()) + assert.Empty(t, tenant.VaultConnections()) + }) + + t.Run("fails when the portal belongs to an application setup did not create", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + runSetup(t, tenant, consumerOptions()) + + for _, client := range tenant.Clients() { + tenant.DeleteClient(client.ID) + } + + deps, _, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + var taken *portals.SlugTakenError + require.ErrorAs(t, err, &taken) + assert.Equal(t, "acme-account", taken.Slug) + }) + + t.Run("is asked again when there is a terminal", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + takenByAnother(tenant) + deps, reporter, prompter := newSetupDeps(tenant) + deps.Interactive = true + prompter.AskFunc = func(string, string, string) (string, error) { return "acme-account-2", nil } + + result, err := portals.Setup(context.Background(), consumerOptions(), deps) + + require.NoError(t, err) + assert.Equal(t, "acme-account-2", result.Portal.Slug) + assert.True(t, reporter.Contains("warn", `The slug "acme-account" is already taken.`)) + }) + + t.Run("is asked again until the slug is valid, with the next free one as the suggestion", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + takenByAnother(tenant) + deps, reporter, prompter := newSetupDeps(tenant) + deps.Interactive = true + + answers := []string{"Not Valid", "acme-account-3"} + var suggestions []string + + prompter.AskFunc = func(_, defaultValue, _ string) (string, error) { + suggestions = append(suggestions, defaultValue) + answer := answers[0] + answers = answers[1:] + + return answer, nil + } + + result, err := portals.Setup(context.Background(), consumerOptions(), deps) + + require.NoError(t, err) + assert.Equal(t, "acme-account-3", result.Portal.Slug) + assert.Equal(t, []string{"acme-account-2", "acme-account-2"}, suggestions) + assert.True(t, reporter.Contains("error", "The slug must start with a lowercase letter")) + }) +} + +func TestSetup_ARefusedPermissionStopsBeforeAnythingIsCreated(t *testing.T) { + t.Parallel() + + t.Run("missing scopes name what to grant", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.Deny("POST portals", "Insufficient scope, expected any of: create:portals") + tenant.Deny( + "POST forms/import", + "Insufficient scope, expected all of: create:forms,create:flows,create:flows_vault_connections,read:flows_vault_connections", + ) + deps, _, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + var missing *portals.MissingScopesError + require.ErrorAs(t, err, &missing) + assert.Equal(t, []string{ + "create:flows", "create:flows_vault_connections", "create:forms", "create:portals", "read:flows_vault_connections", + }, missing.Scopes) + assert.Empty(t, tenant.Clients(), "the checks run before any create") + }) + + t.Run("the scopes are listed once, by the error, with one login command", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.Deny("POST portals", "Insufficient scope, expected any of: create:portals") + deps, reporter, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + var missing *portals.MissingScopesError + require.ErrorAs(t, err, &missing) + assert.Equal(t, "The CLI session is missing 1 permission(s) setup needs: create:portals. Nothing was created", err.Error()) + + logins := 0 + + for _, fix := range portals.FixesOf(err) { + if strings.Contains(fix, "auth0 login --scopes") { + logins++ + } + } + + assert.Equal(t, 1, logins) + + for _, line := range reporter.Lines() { + assert.NotContains(t, line, "create:portals", "the scopes are not shown before the error reports them") + } + }) + + t.Run("a 403 that names no scope is not reported as a pass", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.FailNext("GET portals", http.StatusForbidden) + deps, reporter, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + var refused *portals.PermissionCheckError + require.ErrorAs(t, err, &refused) + assert.Contains(t, err.Error(), `"list portals" with HTTP 403`) + assert.Contains(t, err.Error(), "not enabled for acme.us.auth0.com") + assert.Empty(t, tenant.Clients()) + assert.Empty(t, tenant.ResourceServers()) + assert.False(t, reporter.Contains("result", "every permission")) + }) + + t.Run("a feature that is not enabled", func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, _, _ := newSetupDeps(tenant) + deps.Prober = failingProber{err: &portals.APIError{Status: 403, Code: "feature_not_enabled", Message: "Feature not enabled"}} + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + var notEnabled *portals.FeatureNotEnabledError + require.ErrorAs(t, err, ¬Enabled) + assert.Empty(t, tenant.Clients()) + }) +} + +// failingProber fails every check with the same error. +type failingProber struct { + err error +} + +func (p failingProber) Probe(context.Context, string, string, any) error { return p.err } + +func TestSetup_ACheckThatCouldNotBeVerifiedIsAWarningNotAPass(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.FailNext("GET portals", http.StatusNotFound) + deps, reporter, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + require.NoError(t, err) + assert.True(t, reporter.Contains("warn", `Could not verify "list portals"`)) + assert.True(t, reporter.Contains("result", "No permission is missing, but the checks above could not be verified")) + assert.False(t, reporter.Contains("result", "every permission")) +} + +func TestSetup_ACustomDomainIsUsedForTheURLAndTheBackchannelLogout(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.SetCustomDomain(&portals.CustomDomain{Domain: "auth.acme.com", Status: "ready"}) + + result := runSetup(t, tenant, consumerOptions()) + + assert.Equal(t, "https://auth.acme.com/portals/acme-account", result.Portal.URL) + + client := clientNamed(t, tenant, "Acme Account") + assert.Equal(t, []string{"https://auth.acme.com/portals/auth/callback"}, client.Callbacks) + assert.Equal(t, []string{"https://auth.acme.com"}, client.AllowedLogoutURLs) + require.NotNil(t, client.OIDCLogout, "back-channel logout is set on a custom domain") + assert.Equal(t, []string{"https://auth.acme.com/portals/auth/backchannel-logout"}, client.OIDCLogout.BackchannelLogoutURLs) + assert.Equal(t, "custom", client.OIDCLogout.Mode) +} + +func TestSetup_ACustomDomainThatIsNotReadyIsNotUsed(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.SetCustomDomain(&portals.CustomDomain{Domain: "auth.acme.com", Status: "pending_verification"}) + + result := runSetup(t, tenant, consumerOptions()) + + assert.Equal(t, "https://acme.us.auth0.com/portals/acme-account", result.Portal.URL) + assert.Nil(t, clientNamed(t, tenant, "Acme Account").OIDCLogout) +} + +func TestSetup_TheFormsVaultIsRefreshedWhenItsGrantGainsAScope(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tenant := portalstest.New() + runSetup(t, tenant, consumerOptions()) + + vaultApp := clientNamed(t, tenant, portals.FormsVaultName()) + + for _, grant := range tenant.Grants() { + if grant.ClientID == vaultApp.ID { + require.NoError(t, tenant.UpdateClientGrantScope(ctx, grant.ID, []string{"update:users"})) + } + } + + deps, reporter, _ := newSetupDeps(tenant) + + rerun, err := portals.Setup(ctx, consumerOptions(), deps) + + require.NoError(t, err) + assert.Equal(t, portals.StatusReused, rerun.Vault.Status) + assert.Equal(t, portals.FormsVaultScopes(), tenant.VaultMintedScopes(rerun.Vault.ID)) + assert.True(t, reporter.Contains("item", "Forms vault refreshed with the updated permissions")) +} + +// What a person can get wrong in the options is told before anything is sent, even before the check +// of the permissions: a session that lacks a scope should not have to fix it only to be told about a +// typo in the slug. +func TestSetup_RejectsWhatItCannotRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + options portals.SetupOptions + want string + wantErr any + }{ + { + name: "a slug the API would refuse", + options: portals.SetupOptions{Template: portals.TemplateConsumer, Name: "Acme", Slug: "Not Valid"}, + want: `The slug "Not Valid" must match ^[a-z][a-z0-9-]{0,99}$`, + wantErr: new(*portals.UsageError), + }, + { + name: "a template nobody knows", + options: portals.SetupOptions{Template: "enterprise", Name: "Acme", Slug: "acme"}, + want: `unknown template "enterprise": use consumer or business`, + wantErr: new(*portals.UsageError), + }, + { + name: "a name that is blank", + options: portals.SetupOptions{Template: portals.TemplateConsumer, Name: " ", Slug: "acme"}, + want: "A portal needs a name", + wantErr: new(*portals.UsageError), + }, + { + name: "a business portal with nobody to ask for the user", + options: portals.SetupOptions{Template: portals.TemplateBusiness, Name: "Acme", Slug: "acme"}, + want: "A Business portal needs the user who will test it", + wantErr: new(*portals.BusinessAccessError), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, _, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), tt.options, deps) + + require.ErrorAs(t, err, tt.wantErr) + assert.Equal(t, tt.want, err.Error()) + assert.Empty(t, tenant.Calls(), "nothing is sent, not even a check of the permissions") + }) + } +} + +func TestSetup_ANameThatIsInUseByAnotherApplicationIsNeverReused(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.AddClient(portals.Client{ID: "cli_manual", Secret: "x", ClientSettings: portals.ClientSettings{Name: "Acme Account (Universal Portals)"}}) + deps, _, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + var inUse *portals.NameInUseError + require.ErrorAs(t, err, &inUse) + assert.Equal(t, + `An application named "Acme Account (Universal Portals)" already exists and was not created by this setup. Rename or delete it, then rerun`, + err.Error(), + ) + assert.Empty(t, tenant.Portals()) +} + +func TestSetup_ASlugTakenWhileSetupRuns(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + tenant.FailNext("POST portals", http.StatusConflict) + deps, _, _ := newSetupDeps(tenant) + + _, err := portals.Setup(context.Background(), consumerOptions(), deps) + + var race *portals.SlugRaceError + require.ErrorAs(t, err, &race) + assert.Equal(t, `The slug "acme-account" was taken while setup was running. Rerun with a different --name and --slug`, err.Error()) +} + +func TestAskForMissing(t *testing.T) { + t.Parallel() + + answerWithDefaults := func(prompter *portalstest.Prompter) { + prompter.AskFunc = func(_, defaultValue, _ string) (string, error) { return defaultValue, nil } + prompter.ChooseFunc = func(string, []portals.Choice) (int, error) { return 1, nil } + } + + t.Run("it asks for the kind, the name and the slug, in that order", func(t *testing.T) { + t.Parallel() + + prompter := &portalstest.Prompter{} + answerWithDefaults(prompter) + + options, err := portals.AskForMissing(portals.SetupOptions{}, prompter) + + require.NoError(t, err) + assert.Equal(t, portals.SetupOptions{Template: portals.TemplateBusiness, Name: "My Portal", Slug: "my-portal"}, options) + assert.Equal(t, []string{"Which kind of portal do you want to set up?", "Portal name", "URL slug"}, prompter.Questions()) + }) + + t.Run("the slug defaults to the one made from the name", func(t *testing.T) { + t.Parallel() + + prompter := &portalstest.Prompter{} + answerWithDefaults(prompter) + + options, err := portals.AskForMissing(portals.SetupOptions{Template: portals.TemplateConsumer, Name: "Acme Admin"}, prompter) + + require.NoError(t, err) + assert.Equal(t, "acme-admin", options.Slug) + assert.Equal(t, []string{"URL slug"}, prompter.Questions()) + }) + + t.Run("it asks for nothing that was given", func(t *testing.T) { + t.Parallel() + + given := portals.SetupOptions{Template: portals.TemplateConsumer, Name: "Acme", Slug: "acme-portal", UserQuery: "jane@acme.com"} + + options, err := portals.AskForMissing(given, &portalstest.Prompter{}) + + require.NoError(t, err) + assert.Equal(t, given, options) + }) + + t.Run("a cancel at a question is passed on", func(t *testing.T) { + t.Parallel() + + prompter := &portalstest.Prompter{AskFunc: func(string, string, string) (string, error) { return "", portals.ErrCancelled }} + + _, err := portals.AskForMissing(portals.SetupOptions{Template: portals.TemplateConsumer}, prompter) + + assert.ErrorIs(t, err, portals.ErrCancelled) + }) +} + +func TestSetup_ASlugLeftEmptyIsMadeFromTheName(t *testing.T) { + t.Parallel() + + tenant := portalstest.New() + deps, _, _ := newSetupDeps(tenant) + + result, err := portals.Setup(context.Background(), portals.SetupOptions{Template: portals.TemplateConsumer, Name: "Acme Account"}, deps) + + require.NoError(t, err) + assert.Equal(t, "acme-account", tenant.Portals()[0].Slug) + assert.Equal(t, portals.StatusCreated, result.Portal.Status) +} diff --git a/internal/portals/slug.go b/internal/portals/slug.go new file mode 100644 index 000000000..f487cf8a3 --- /dev/null +++ b/internal/portals/slug.go @@ -0,0 +1,45 @@ +package portals + +import ( + "regexp" + "strings" +) + +// SlugPattern is what the API accepts as the slug of a portal. +const SlugPattern = "^[a-z][a-z0-9-]{0,99}$" + +var ( + slugRegexp = regexp.MustCompile(SlugPattern) + notSlugCharacters = regexp.MustCompile(`[^a-z0-9]+`) + notALetterAtStart = regexp.MustCompile(`^[^a-z]+`) +) + +const ( + maxSlugLength = 100 + // The slug of a portal whose name leaves nothing to make one from. + fallbackSlug = "my-portal" +) + +// IsValidSlug reports whether the API accepts the slug. +func IsValidSlug(slug string) bool { + return slugRegexp.MatchString(slug) +} + +// SlugFromName makes the default slug of a portal from its name, for example "Acme Admin" becomes +// "acme-admin". +func SlugFromName(name string) string { + slug := notSlugCharacters.ReplaceAllString(strings.ToLower(name), "-") + slug = strings.Trim(slug, "-") + slug = notALetterAtStart.ReplaceAllString(slug, "") + + if len(slug) > maxSlugLength { + slug = slug[:maxSlugLength] + } + + slug = strings.TrimRight(slug, "-") + if slug == "" { + return fallbackSlug + } + + return slug +} diff --git a/internal/portals/slug_test.go b/internal/portals/slug_test.go new file mode 100644 index 000000000..80213d570 --- /dev/null +++ b/internal/portals/slug_test.go @@ -0,0 +1,68 @@ +package portals_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/portals" +) + +func TestIsValidSlug(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + slug string + want bool + }{ + {name: "lowercase with a hyphen", slug: "acme-admin", want: true}, + {name: "a single letter", slug: "a", want: true}, + {name: "the longest one", slug: "a" + strings.Repeat("b", 99), want: true}, + {name: "a space and capitals", slug: "My Portal"}, + {name: "a digit first", slug: "1portal"}, + {name: "a hyphen first", slug: "-portal"}, + {name: "an underscore", slug: "portal_one"}, + {name: "empty", slug: ""}, + {name: "longer than 100 characters", slug: "a" + strings.Repeat("b", 100)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, portals.IsValidSlug(tt.slug)) + }) + } +} + +func TestSlugFromName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "words become a hyphenated slug", in: "Acme Admin", want: "acme-admin"}, + {name: "punctuation and runs of it become one hyphen", in: "Acme & Co., Ltd!", want: "acme-co-ltd"}, + {name: "leading digits are dropped, because a slug starts with a letter", in: "123 portal", want: "portal"}, + {name: "hyphens at the ends are dropped", in: "--Acme--", want: "acme"}, + {name: "a name with nothing to make a slug from", in: "!!!", want: "my-portal"}, + {name: "an empty name", in: "", want: "my-portal"}, + {name: "accents are not letters of a slug", in: "Café Ñandú", want: "caf-and"}, + {name: "a long name is cut to 100 characters without ending in a hyphen", in: strings.Repeat("ab ", 60), want: strings.TrimRight(strings.Repeat("ab-", 60)[:100], "-")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := portals.SlugFromName(tt.in) + + assert.Equal(t, tt.want, got) + assert.True(t, portals.IsValidSlug(got), "the slug of any name is valid") + }) + } +} diff --git a/internal/portals/store.go b/internal/portals/store.go new file mode 100644 index 000000000..dd19fb9af --- /dev/null +++ b/internal/portals/store.go @@ -0,0 +1,129 @@ +package portals + +import "context" + +// The stores are the calls to the tenant that setup and audit make. Each method is one request +// to the Management API, so the order of the work stays in this package. A store returns an +// *APIError for a failed request, and the package decides what a status means: a 404 can be an +// answer, a 409 a race, and a 403 a missing scope. + +// ResourceServerStore reads and creates resource servers. +type ResourceServerStore interface { + ListResourceServers(ctx context.Context) ([]ResourceServer, error) + CreateResourceServer(ctx context.Context, spec ResourceServerSpec) (ResourceServer, error) +} + +// ClientStore reads and writes applications. +type ClientStore interface { + // ListClients returns the ID, name, metadata and session transfer of every application. + ListClients(ctx context.Context) ([]Client, error) + // ReadClient returns the whole configuration of an application, without its secret. + ReadClient(ctx context.Context, id string) (Client, error) + // ReadClientSecret returns the secret of an application. + ReadClientSecret(ctx context.Context, id string) (string, error) + // CreateClient creates an application and returns it with its secret. + CreateClient(ctx context.Context, settings ClientSettings) (Client, error) + // UpdateClient changes the settings that are set, and keeps the others. + UpdateClient(ctx context.Context, id string, settings ClientSettings) error +} + +// ClientGrantStore reads and writes the grants of an application. +type ClientGrantStore interface { + ListClientGrants(ctx context.Context, clientID string) ([]ClientGrant, error) + CreateClientGrant(ctx context.Context, spec ClientGrantSpec) (ClientGrant, error) + UpdateClientGrantScope(ctx context.Context, id string, scope []string) error + DeleteClientGrant(ctx context.Context, id string) error +} + +// VaultStore reads and writes the connections of the Flows vault. +type VaultStore interface { + ListVaultConnections(ctx context.Context) ([]VaultConnection, error) + CreateVaultConnection(ctx context.Context, name string, setup VaultSetup) (VaultConnection, error) + // UpdateVaultConnection sends the setup again, so the vault signs in again with the grants in force. + UpdateVaultConnection(ctx context.Context, id string, setup VaultSetup) error +} + +// FormStore reads and imports forms. +type FormStore interface { + ListForms(ctx context.Context) ([]Form, error) + // ReadForm fails with a 404, or a 400 for an ID the API cannot match, when the form is missing. + ReadForm(ctx context.Context, id string) (FormDetail, error) + // ImportForm creates a form, and its flow, from a document. + ImportForm(ctx context.Context, document any) (ImportedForm, error) +} + +// PortalStore reads and writes portals. +type PortalStore interface { + ListPortals(ctx context.Context) ([]PortalSummary, error) + ReadPortal(ctx context.Context, id string) (Portal, error) + CreatePortal(ctx context.Context, request CreatePortalRequest) (Portal, error) + UpdatePortal(ctx context.Context, id string, patch PortalPatch) error +} + +// CustomDomainStore reads the custom domain of the tenant. +type CustomDomainStore interface { + // ReadDefaultCustomDomain fails with a 404 when the tenant has none. + ReadDefaultCustomDomain(ctx context.Context) (CustomDomain, error) +} + +// UserStore reads users. +type UserStore interface { + // SearchUsersByEmail returns the users whose email contains the text, like the search does. + SearchUsersByEmail(ctx context.Context, email string) ([]User, error) + // ReadUser fails with a 404 when the user does not exist. + ReadUser(ctx context.Context, id string) (User, error) + ListUserOrganizations(ctx context.Context, userID string) ([]Organization, error) +} + +// OrganizationStore reads and writes organizations. +type OrganizationStore interface { + ListOrganizations(ctx context.Context) ([]Organization, error) + ReadOrganization(ctx context.Context, id string) (Organization, error) + CreateOrganization(ctx context.Context, spec OrganizationSpec) (Organization, error) + // ListOrganizationConnections returns the IDs of the connections enabled for the organization. + ListOrganizationConnections(ctx context.Context, id string) ([]string, error) + // EnableOrganizationConnection enables a connection without assigning membership on login. + EnableOrganizationConnection(ctx context.Context, id, connectionID string) error + AddOrganizationMember(ctx context.Context, id, userID string) error + ListMemberRoles(ctx context.Context, id, userID string) ([]Role, error) + AssignMemberRole(ctx context.Context, id, userID, roleID string) error +} + +// RoleStore reads and writes roles. +type RoleStore interface { + ListRoles(ctx context.Context) ([]Role, error) + CreateRole(ctx context.Context, spec RoleSpec) (Role, error) + ListRolePermissions(ctx context.Context, roleID string) ([]Permission, error) + AddRolePermissions(ctx context.Context, roleID string, permissions []Permission) error +} + +// ConnectionStore reads connections. +type ConnectionStore interface { + // ListDatabaseConnections returns the connections that use the Auth0 database. + ListDatabaseConnections(ctx context.Context) ([]Connection, error) + // ListClientConnections returns the connections enabled for an application. + ListClientConnections(ctx context.Context, clientID string) ([]Connection, error) +} + +// Prober sends the requests that check a permission without changing anything. +type Prober interface { + // Probe sends one request. A write probe carries a body the API rejects, or names a + // resource that does not exist, so it can only fail. + Probe(ctx context.Context, method, path string, body any) error +} + +// API is everything setup and audit need from a tenant. +type API interface { + ResourceServerStore + ClientStore + ClientGrantStore + VaultStore + FormStore + PortalStore + CustomDomainStore + UserStore + OrganizationStore + RoleStore + ConnectionStore + Prober +} diff --git a/internal/portals/template.go b/internal/portals/template.go new file mode 100644 index 000000000..def57f3c3 --- /dev/null +++ b/internal/portals/template.go @@ -0,0 +1,16 @@ +package portals + +// Template is the kind of portal to set up. +type Template string + +const ( + // TemplateConsumer is for end users who manage their own account. + TemplateConsumer Template = "consumer" + // TemplateBusiness is for organization administrators who manage their organization. + TemplateBusiness Template = "business" +) + +// IsValid reports whether the template is one the package knows. +func (t Template) IsValid() bool { + return t == TemplateConsumer || t == TemplateBusiness +} diff --git a/internal/portals/testdata/consumer-setup.json b/internal/portals/testdata/consumer-setup.json new file mode 100644 index 000000000..e4244d5dd --- /dev/null +++ b/internal/portals/testdata/consumer-setup.json @@ -0,0 +1,83 @@ +{ + "template": "consumer", + "portal": { + "id": "up_0000000000000000000014", + "slug": "acme-account", + "url": "https://acme.us.auth0.com/portals/acme-account", + "status": "created" + }, + "application": { + "clientId": "00000000000000000000000000000003", + "status": "created" + }, + "vault": { + "id": "ac_0000000000000000000007", + "status": "created" + }, + "forms": { + "personalInfo": { + "id": "ap_0000000000000000000008", + "status": "created" + }, + "communicationPreferences": { + "id": "ap_0000000000000000000010", + "status": "created" + }, + "privacyConsent": { + "id": "ap_0000000000000000000012", + "status": "created" + } + }, + "resources": [ + { + "type": "resource_server", + "id": "000000000000000000000002", + "status": "created" + }, + { + "type": "application", + "id": "00000000000000000000000000000003", + "status": "created" + }, + { + "type": "client_grant", + "id": "cgr_0000000000000004", + "status": "created" + }, + { + "type": "forms_vault_client", + "id": "00000000000000000000000000000005", + "status": "created" + }, + { + "type": "forms_vault_grant", + "id": "cgr_0000000000000006", + "status": "created" + }, + { + "type": "forms_vault", + "id": "ac_0000000000000000000007", + "status": "created" + }, + { + "type": "form", + "id": "ap_0000000000000000000008", + "status": "created" + }, + { + "type": "form", + "id": "ap_0000000000000000000010", + "status": "created" + }, + { + "type": "form", + "id": "ap_0000000000000000000012", + "status": "created" + }, + { + "type": "portal", + "id": "up_0000000000000000000014", + "status": "created" + } + ] +} diff --git a/internal/portals/types.go b/internal/portals/types.go new file mode 100644 index 000000000..f01c60c40 --- /dev/null +++ b/internal/portals/types.go @@ -0,0 +1,180 @@ +package portals + +// ResourceServer is an API registered in the tenant. +type ResourceServer struct { + ID string + Identifier string + Name string +} + +// ResourceServerSpec describes a resource server to create. +type ResourceServerSpec struct { + Name string + Identifier string + // SkipConsentForVerifiableFirstPartyClients stops first-party applications from asking for consent. + SkipConsentForVerifiableFirstPartyClients bool + // TokenDialect is the token profile, for example "rfc9068_profile". + TokenDialect string +} + +// Client is an application. A list returns only the ID, name, metadata and session transfer, +// a read returns the whole configuration, and only a create returns the secret. +type Client struct { + ID string + Secret string + ClientSettings +} + +// ClientSettings is the configuration of an application. When it updates an application, the +// fields left empty or nil are not sent, so they keep their value. +type ClientSettings struct { + Name string + AppType string + IsFirstParty *bool + OIDCConformant *bool + TokenEndpointAuthMethod string + IPHeaderTrusted *bool + RequireProofOfPossession *bool + Callbacks []string + AllowedLogoutURLs []string + GrantTypes []string + OrganizationUsage string + OrganizationRequireBehavior string + Metadata map[string]string + RefreshToken *RefreshToken + SessionTransfer *SessionTransfer + OIDCLogout *OIDCLogout +} + +// RefreshToken is the refresh token configuration of an application. +type RefreshToken struct { + RotationType string + ExpirationType string + TokenLifetime *int + IdleTokenLifetime *int + InfiniteIdleTokenLifetime *bool + Policies []RefreshTokenPolicy +} + +// RefreshTokenPolicy limits what a refresh token can ask for on one API. +type RefreshTokenPolicy struct { + Audience string + Scope []string +} + +// SessionTransfer lets an application hand its session to the portal. +type SessionTransfer struct { + AllowedAuthenticationMethods []string + EnforceDeviceBinding string +} + +// OIDCLogout configures back-channel logout. +type OIDCLogout struct { + BackchannelLogoutURLs []string + // Mode is how the initiators are chosen, for example "custom". + Mode string + SelectedInitiators []string +} + +// ClientGrant lets an application call an API with some scopes. +type ClientGrant struct { + ID string + ClientID string + Audience string + Scope []string +} + +// ClientGrantSpec describes a client grant to create. +type ClientGrantSpec struct { + ClientID string + Audience string + Scope []string + // SubjectType is "user" or "client"; empty leaves the API default. + SubjectType string +} + +// VaultConnection is a connection in the Flows vault. +type VaultConnection struct { + ID string + Name string +} + +// VaultSetup is how the vault signs in to the tenant. +type VaultSetup struct { + Domain string + ClientID string + ClientSecret string +} + +// Form is a Forms form in the list of the tenant. +type Form struct { + ID string + Name string +} + +// FormDetail is a form read on its own. +type FormDetail struct { + ID string + // FlowID is the flow the form runs, empty when it runs none. + FlowID string +} + +// ImportedForm is the result of importing a form. +type ImportedForm struct { + ID string + // FlowID is the flow the import created, empty when it created none. + FlowID string +} + +// CustomDomain is the default custom domain of the tenant. +type CustomDomain struct { + Domain string + Status string +} + +// IsReady reports whether the portal is served from the domain: it is verified, and it has a name. +func (d CustomDomain) IsReady() bool { return d.Status == "ready" && d.Domain != "" } + +// User is a user of the tenant. +type User struct { + ID string + Email string + Name string +} + +// Organization is an organization of the tenant. +type Organization struct { + ID string + Name string + DisplayName string +} + +// OrganizationSpec describes an organization to create. +type OrganizationSpec struct { + Name string + DisplayName string +} + +// Role is a role of the tenant. +type Role struct { + ID string + Name string +} + +// RoleSpec describes a role to create. +type RoleSpec struct { + Name string + Description string +} + +// Permission is a scope of an API, granted through a role. +type Permission struct { + Name string + ResourceServerIdentifier string +} + +// Connection is a connection of the tenant. +type Connection struct { + ID string + Name string +} diff --git a/internal/portals/ui.go b/internal/portals/ui.go new file mode 100644 index 000000000..e58200324 --- /dev/null +++ b/internal/portals/ui.go @@ -0,0 +1,68 @@ +package portals + +import "errors" + +// ErrCancelled is returned by a Prompter when the person at the terminal cancels a question. +var ErrCancelled = errors.New("cancelled") + +// Choice is one option of a question with a single answer. +type Choice struct { + Label string + Hint string +} + +// ChecklistItem is one option of a question with several answers. +type ChecklistItem struct { + Label string + Hint string + // Checked starts the item selected. + Checked bool +} + +// Prompter asks the person who runs the command. Setup and audit take a Prompter, so tests can +// answer without a terminal. +type Prompter interface { + // Ask returns the answer, or defaultValue when the answer is empty. The example is shown + // when there is no default. + Ask(question, defaultValue, example string) (string, error) + Confirm(question string, defaultValue bool) (bool, error) + // Choose returns the index of the option picked. + Choose(question string, options []Choice) (int, error) + // Checklist returns the indexes of the items left selected, in order. + Checklist(question string, items []ChecklistItem) ([]int, error) +} + +// Reporter shows the progress of the work. Everything it shows is for a person: the result of a +// command that was asked for JSON goes elsewhere. +// +// Output is sequential and each line stays where it was printed. A section starts a block, its +// items follow, and its result closes it. +type Reporter interface { + // Banner shows the title and the tenant every change will go to, before any question. + Banner(title, tenantDomain string) + // Section starts a block of items. + Section(title string) + // Item is one line of a section. The outcome, such as "created", is shown dimmed after it. + Item(text, outcome string) + // Fixed is a change the command made, shown with a check mark. + Fixed(text, summary string) + // Result closes a section. + Result(text string) + Info(text string) + Warn(text string) + Error(text string) + // Detail is a dimmed line. + Detail(text string) + // Reference shows an ID that links to its page in the dashboard. + Reference(label, id, url string) + // Ready shows the URL of the portal that was set up. + Ready(url string) + // Cancelled closes the output when the person cancels. + Cancelled(message string) + // Newline leaves a blank line. + Newline() + // Table shows rows under a header. The first column is a symbol. + Table(headers []string, rows [][]string) + // Task runs work behind a spinner when there is a terminal. + Task(title string, work func() error) error +}