From aced94300825b2d7b57e8d9207ba0e6b609faf69 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Thu, 5 Nov 2026 23:55:04 +0100 Subject: [PATCH 01/27] feat(auth0): wrap the calls portals needs Universal Portals setup and audit need a few Management API calls that the SDK has but the CLI wrappers did not expose: - UserAPI.Organizations lists the organizations of a user. - OrganizationAPI.AddConnection, AddMembers and AssignMemberRoles enable a connection, add members and give a member roles. - ClientConnectionAPIV3.Get lists the connections enabled for a client with checkpoint paging, which the page and per_page query rejects. The mocks are regenerated with make test-mocks. Co-Authored-By: Claude --- internal/auth0/auth0.go | 2 + internal/auth0/client_connection_v3.go | 30 +++++++++ .../auth0/mock/client_connection_v3_mock.go | 64 +++++++++++++++++++ internal/auth0/mock/organization_mock.go | 57 +++++++++++++++++ internal/auth0/mock/user_mock.go | 20 ++++++ internal/auth0/organization.go | 15 +++++ internal/auth0/user.go | 3 + 7 files changed, 191 insertions(+) create mode 100644 internal/auth0/client_connection_v3.go create mode 100644 internal/auth0/mock/client_connection_v3_mock.go 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 From 08112d4a5971ade09669ac86bf41b1588acf28e0 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 00:01:02 +0100 Subject: [PATCH 02/27] feat(portals): add the stores and a fake tenant Universal Portals setup and audit will run on small stores, one per kind of resource, so the order of the work lives in internal/portals and the SDK stays in the CLI. A failed request is an *APIError with the status and the errorCode, and the package decides what it means. portalstest is an in-memory tenant that implements every store. It names requests like the Management API routes ("POST clients"), so a test can deny a scope or fail a request once, and it keeps the rules setup relies on: conflicts on duplicate identifiers, slugs and names, secrets only on create and on a read of the secret, and validation errors for write probes with an empty body. IDs come from one counter with the shape of the real IDs, so output can be compared exactly. Co-Authored-By: Claude --- internal/portals/errors.go | 62 ++ internal/portals/errors_test.go | 76 ++ internal/portals/portal.go | 84 ++ internal/portals/portals.go | 5 + internal/portals/portalstest/seed.go | 227 ++++ internal/portals/portalstest/tenant.go | 1068 +++++++++++++++++++ internal/portals/portalstest/tenant_test.go | 252 +++++ internal/portals/store.go | 128 +++ internal/portals/types.go | 177 +++ 9 files changed, 2079 insertions(+) create mode 100644 internal/portals/errors.go create mode 100644 internal/portals/errors_test.go create mode 100644 internal/portals/portal.go create mode 100644 internal/portals/portals.go create mode 100644 internal/portals/portalstest/seed.go create mode 100644 internal/portals/portalstest/tenant.go create mode 100644 internal/portals/portalstest/tenant_test.go create mode 100644 internal/portals/store.go create mode 100644 internal/portals/types.go diff --git a/internal/portals/errors.go b/internal/portals/errors.go new file mode 100644 index 000000000..cb929bd79 --- /dev/null +++ b/internal/portals/errors.go @@ -0,0 +1,62 @@ +package portals + +import ( + "errors" + "fmt" + "net/http" +) + +// 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 +} + +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 +} diff --git a/internal/portals/errors_test.go b/internal/portals/errors_test.go new file mode 100644 index 000000000..2317f102b --- /dev/null +++ b/internal/portals/errors_test.go @@ -0,0 +1,76 @@ +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)) + }) + } +} diff --git a/internal/portals/portal.go b/internal/portals/portal.go new file mode 100644 index 000000000..c8e60a64e --- /dev/null +++ b/internal/portals/portal.go @@ -0,0 +1,84 @@ +package portals + +// 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. +type Page struct { + Title string `json:"title"` + Slug string `json:"slug"` + Components []Component `json:"components,omitempty"` +} + +// Component is a block of a page. The config depends on the type, and a section holds its +// blocks in config.children. +type Component struct { + Type string `json:"type"` + Config map[string]any `json:"config,omitempty"` +} + +// 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/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..35aa90450 --- /dev/null +++ b/internal/portals/portalstest/seed.go @@ -0,0 +1,227 @@ +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 +} diff --git a/internal/portals/portalstest/tenant.go b/internal/portals/portalstest/tenant.go new file mode 100644 index 000000000..8c809d315 --- /dev/null +++ b/internal/portals/portalstest/tenant.go @@ -0,0 +1,1068 @@ +// 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" + "fmt" + "net/http" + "regexp" + "slices" + "strings" + "sync" + + "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 { + t.calls = append(t.calls, key) + + if message, denied := t.denials[key]; denied { + return &portals.APIError{Status: http.StatusForbidden, Code: "insufficient_scope", Message: message} + } + + if status, failing := t.failures[key]; failing { + delete(t.failures, key) + return &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) +} + +func notFound(message string) error { + return &portals.APIError{Status: http.StatusNotFound, Message: message} +} + +func conflict(message string) error { + return &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. +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 changes.Callbacks != nil { + current.Callbacks = changes.Callbacks + } + + if changes.AllowedLogoutURLs != nil { + current.AllowedLogoutURLs = changes.AllowedLogoutURLs + } + + if changes.GrantTypes != nil { + 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 +} + +// 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{}, &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{}, &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.begin(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. + if method == http.MethodPost && isEmptyBody(body) { + return &portals.APIError{ + Status: http.StatusBadRequest, + Message: "Payload validation error: 'Missing required property'", + } + } + + 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..6cd8dc915 --- /dev/null +++ b/internal/portals/portalstest/tenant_test.go @@ -0,0 +1,252 @@ +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) +} diff --git a/internal/portals/store.go b/internal/portals/store.go new file mode 100644 index 000000000..c8eb034c7 --- /dev/null +++ b/internal/portals/store.go @@ -0,0 +1,128 @@ +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 +} + +// 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/types.go b/internal/portals/types.go new file mode 100644 index 000000000..3994bd9b6 --- /dev/null +++ b/internal/portals/types.go @@ -0,0 +1,177 @@ +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 +} + +// 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 +} From ba3e9ddf0fd836196a0fb7ab0d72caeea3d216b7 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 09:40:59 +0100 Subject: [PATCH 03/27] feat(cli): implement the portals stores on the SDK portalsTenant implements portals.API on the typed v1 and v3 clients of the CLI. Only the requests with no typed call go through the raw HTTP client: portals, the import and the read of a form (a typed read fails on a node the SDK does not know), and the permission probes. Every failure becomes a *portals.APIError with the status, the errorCode and the message, whichever client made the request. Listings stop after 100 pages of 100 items, and the connections of an application page by checkpoint because that endpoint rejects page and per_page. A raw request times out after 30 seconds. The tests run the real v1 and v3 clients against a local server, so they check the requests the SDK really sends: the body of each create and update, the query of each list, and the escaping of IDs. Co-Authored-By: Claude --- internal/cli/universal_portals_tenant.go | 153 +++++++ internal/cli/universal_portals_tenant_apps.go | 431 ++++++++++++++++++ .../cli/universal_portals_tenant_apps_test.go | 356 +++++++++++++++ .../cli/universal_portals_tenant_directory.go | 281 ++++++++++++ ...universal_portals_tenant_directory_test.go | 207 +++++++++ internal/cli/universal_portals_tenant_raw.go | 297 ++++++++++++ .../cli/universal_portals_tenant_raw_test.go | 289 ++++++++++++ internal/cli/universal_portals_tenant_test.go | 307 +++++++++++++ 8 files changed, 2321 insertions(+) create mode 100644 internal/cli/universal_portals_tenant.go create mode 100644 internal/cli/universal_portals_tenant_apps.go create mode 100644 internal/cli/universal_portals_tenant_apps_test.go create mode 100644 internal/cli/universal_portals_tenant_directory.go create mode 100644 internal/cli/universal_portals_tenant_directory_test.go create mode 100644 internal/cli/universal_portals_tenant_raw.go create mode 100644 internal/cli/universal_portals_tenant_raw_test.go create mode 100644 internal/cli/universal_portals_tenant_test.go diff --git a/internal/cli/universal_portals_tenant.go b/internal/cli/universal_portals_tenant.go new file mode 100644 index 000000000..dcd5a395b --- /dev/null +++ b/internal/cli/universal_portals_tenant.go @@ -0,0 +1,153 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "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 +} + +var _ portals.API = (*portalsTenant)(nil) + +func newPortalsTenant(api *auth0.API, apiv3 *auth0.APIV3) *portalsTenant { + return &portalsTenant{api: api, apiv3: apiv3} +} + +// 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} + } + + if v3, ok := errors.AsType[*core.APIError](err); ok { + _, body, _ := strings.Cut(v3.Error(), ": ") + + return apiErrorFromBody(v3.StatusCode, []byte(body)) + } + + 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))} + + 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 := 0; page < portalsMaxPages; page++ { + 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..d5ace11c2 --- /dev/null +++ b/internal/cli/universal_portals_tenant_apps.go @@ -0,0 +1,431 @@ +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.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.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.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 != 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(err) +} + +// 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.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.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..3abbec9ed --- /dev/null +++ b/internal/cli/universal_portals_tenant_apps_test.go @@ -0,0 +1,356 @@ +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"]) + }) +} diff --git a/internal/cli/universal_portals_tenant_directory.go b/internal/cli/universal_portals_tenant_directory.go new file mode 100644 index 000000000..37f3559ef --- /dev/null +++ b/internal/cli/universal_portals_tenant_directory.go @@ -0,0 +1,281 @@ +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.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 + } + + 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.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.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 + } + + 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.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.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 + } + + 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.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..7f8cfe463 --- /dev/null +++ b/internal/cli/universal_portals_tenant_directory_test.go @@ -0,0 +1,207 @@ +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, + ) +} diff --git a/internal/cli/universal_portals_tenant_raw.go b/internal/cli/universal_portals_tenant_raw.go new file mode 100644 index 000000000..6b5e58a31 --- /dev/null +++ b/internal/cli/universal_portals_tenant_raw.go @@ -0,0 +1,297 @@ +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. +func (t *portalsTenant) rawJSON(ctx context.Context, method, path string, body any) (json.RawMessage, error) { + ctx, cancel := context.WithTimeout(ctx, portalsRequestTimeout) + defer cancel() + + route, query, hasQuery := strings.Cut(path, "?") + + uri := t.api.HTTPClient.URI(strings.Split(route, "/")...) + if hasQuery { + uri += "?" + 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 := 0; page < portalsMaxPages; page++ { + 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.rawJSON(ctx, http.MethodGet, "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 != 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.rawJSON(ctx, http.MethodGet, "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 != 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.rawJSON(ctx, http.MethodPatch, "portals/"+id, patch) + + return 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..2517a1375 --- /dev/null +++ b/internal/cli/universal_portals_tenant_raw_test.go @@ -0,0 +1,289 @@ +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 "" +} + +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)) +} From fe93fd15f829e0384676f7afb1821446f683aa0c Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 09:45:00 +0100 Subject: [PATCH 04/27] feat(portals): check the permissions before setup Setup sends one check per capability it needs before it creates anything, so a missing scope is reported once and nothing is left half done. The checks run four at a time and are read in order. A write check sends an empty body the API rejects with a 400, and the checks on a resource that does not exist also accept a 404, so they cover what a rerun updates and what the business template adds: the grants, the vault connection, the organization, its connection and members, and the role and its permissions. The advice for a missing scope depends on how the CLI is signed in. A user login lists every scope the tool uses together with the missing ones, because auth0 login --scopes replaces the scopes of the last login. A client-credentials login only needs the missing scopes added to the grant. The advice is also attached to any request refused for a scope, and the message of a refusal that names no scope points at Universal Portals not being enabled. Co-authored-by: ramya18101 Co-Authored-By: Claude --- internal/portals/errors.go | 104 +++++++++++++ internal/portals/errors_test.go | 55 +++++++ internal/portals/helpers_test.go | 8 + internal/portals/parallel.go | 30 ++++ internal/portals/parallel_test.go | 71 +++++++++ internal/portals/preflight.go | 130 ++++++++++++++++ internal/portals/preflight_test.go | 242 +++++++++++++++++++++++++++++ internal/portals/probes.go | 91 +++++++++++ internal/portals/probes_test.go | 87 +++++++++++ internal/portals/scopes.go | 143 +++++++++++++++++ internal/portals/scopes_test.go | 143 +++++++++++++++++ internal/portals/template.go | 16 ++ 12 files changed, 1120 insertions(+) create mode 100644 internal/portals/helpers_test.go create mode 100644 internal/portals/parallel.go create mode 100644 internal/portals/parallel_test.go create mode 100644 internal/portals/preflight.go create mode 100644 internal/portals/preflight_test.go create mode 100644 internal/portals/probes.go create mode 100644 internal/portals/probes_test.go create mode 100644 internal/portals/scopes.go create mode 100644 internal/portals/scopes_test.go create mode 100644 internal/portals/template.go diff --git a/internal/portals/errors.go b/internal/portals/errors.go index cb929bd79..7d36d317e 100644 --- a/internal/portals/errors.go +++ b/internal/portals/errors.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "net/http" + "slices" + "strings" ) // APIError is a failed Management API request, whatever client made it. @@ -60,3 +62,105 @@ func IsStatus(err error, statuses ...int) bool { 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 +} diff --git a/internal/portals/errors_test.go b/internal/portals/errors_test.go index 2317f102b..580016f63 100644 --- a/internal/portals/errors_test.go +++ b/internal/portals/errors_test.go @@ -74,3 +74,58 @@ func TestStatusCodeAndIsStatus(t *testing.T) { }) } } + +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/helpers_test.go b/internal/portals/helpers_test.go new file mode 100644 index 000000000..ca1ea0965 --- /dev/null +++ b/internal/portals/helpers_test.go @@ -0,0 +1,8 @@ +package portals_test + +import "fmt" + +// 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) +} 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..358f040b8 --- /dev/null +++ b/internal/portals/parallel_test.go @@ -0,0 +1,71 @@ +package portals + +import ( + "sync" + "sync/atomic" + "testing" + + "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. + started.Wait() + 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/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..406f9f9b7 --- /dev/null +++ b/internal/portals/probes.go @@ -0,0 +1,91 @@ +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 ( + fakeGrantID = "cgr_preflight0000000" + fakeVaultID = "ac_preflight0000000000000" + fakeOrganizationID = "org_preflight0000000" + fakeRoleID = "rol_preflight0000000" +) + +// 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 grants and the vault connection it finds. + {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: "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..1c8e87d75 --- /dev/null +++ b/internal/portals/probes_test.go @@ -0,0 +1,87 @@ +package portals_test + +import ( + "context" + "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.Len(t, consumer, 14) + 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.Len(t, business, 21) + 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", "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()) + }) +} + +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/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_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/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 +} From 6992424feb32bc5442bf483300e1d4ed2ccbca32 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 09:54:13 +0100 Subject: [PATCH 05/27] feat(portals): add the terminal output Setup and audit show their progress through a Reporter and ask their questions through a Prompter, two small interfaces in internal/portals, so the flows can run in tests without a terminal. The terminal implementation draws the output on the stream for messages, with a rail on the left and a mark for each kind of line, a rounded title box that shows the tenant before any question, sections with items and results, a table, links to the dashboard on the IDs, and the URL of the portal in a note. A spinner shows while work runs when there is a terminal. Its output for the same calls is the same, byte for byte, as the output of the tool it replaces, which the tests compare against. The questions use survey, and a Ctrl+C comes back as portals.ErrCancelled so a command can say what was and was not done. portalstest gets a scripted prompter and a reporter that records what it was shown. Co-Authored-By: Claude --- internal/cli/universal_portals_prompter.go | 141 +++++++ .../cli/universal_portals_prompter_test.go | 29 ++ internal/cli/universal_portals_terminal.go | 361 ++++++++++++++++++ .../cli/universal_portals_terminal_test.go | 264 +++++++++++++ internal/portals/dashboard.go | 68 ++++ internal/portals/dashboard_test.go | 71 ++++ internal/portals/portalstest/tenant_test.go | 55 +++ internal/portals/portalstest/ui.go | 169 ++++++++ internal/portals/ui.go | 66 ++++ 9 files changed, 1224 insertions(+) create mode 100644 internal/cli/universal_portals_prompter.go create mode 100644 internal/cli/universal_portals_prompter_test.go create mode 100644 internal/cli/universal_portals_terminal.go create mode 100644 internal/cli/universal_portals_terminal_test.go create mode 100644 internal/portals/dashboard.go create mode 100644 internal/portals/dashboard_test.go create mode 100644 internal/portals/portalstest/ui.go create mode 100644 internal/portals/ui.go 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_terminal.go b/internal/cli/universal_portals_terminal.go new file mode 100644 index 000000000..5b1a30d29 --- /dev/null +++ b/internal/cli/universal_portals_terminal.go @@ -0,0 +1,361 @@ +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("") +} + +// 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..128a3eb3f --- /dev/null +++ b/internal/cli/universal_portals_terminal_test.go @@ -0,0 +1,264 @@ +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") +} 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/portalstest/tenant_test.go b/internal/portals/portalstest/tenant_test.go index 6cd8dc915..3469ef02c 100644 --- a/internal/portals/portalstest/tenant_test.go +++ b/internal/portals/portalstest/tenant_test.go @@ -250,3 +250,58 @@ func TestTenant_ReturnsCopies(t *testing.T) { 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")) +} diff --git a/internal/portals/portalstest/ui.go b/internal/portals/portalstest/ui.go new file mode 100644 index 000000000..c0087c597 --- /dev/null +++ b/internal/portals/portalstest/ui.go @@ -0,0 +1,169 @@ +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) } + +// 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/ui.go b/internal/portals/ui.go new file mode 100644 index 000000000..6b221b428 --- /dev/null +++ b/internal/portals/ui.go @@ -0,0 +1,66 @@ +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) + // 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 +} From 03563ba20efd0ace6f386346eab5ea252f73e4a4 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 10:10:42 +0100 Subject: [PATCH 06/27] feat(portals): set up a consumer portal Setup creates a portal with the resources it needs, in an order where the portal comes last, and finds each resource by a stable key before it creates one: the resource server, the application (named after the portal, with its slug in its metadata), its grants, the forms vault application, grant and connection, the three forms, and the portal. Rerunning the same command after a failure reuses what exists, a change of the scopes of the vault grant refreshes the vault, and the settings of an existing application are brought back without touching a session transfer setting that was changed on purpose. An application or a portal that setup did not create is never reused, a taken slug is fixed before anything is created, and the questions for the kind, the name and the slug are asked first. The result is the same JSON as before, in the same order, and the tests compare it with the output of the tool this replaces. The forms and the pages of a portal are JSON in internal/portals/data, generated from the tool this replaces. Only the IDs that are known when setup runs are put in. The fake tenant answers a write probe with an empty body before it applies a failure a test set for the real request, as the API does. Co-Authored-By: Claude --- internal/portals/data.go | 142 ++++ .../data/forms/communication_preferences.json | 115 ++++ .../portals/data/forms/personal_info.json | 142 ++++ .../portals/data/forms/privacy_consent.json | 137 ++++ internal/portals/data/templates/business.json | 149 +++++ internal/portals/data/templates/consumer.json | 126 ++++ internal/portals/data_test.go | 268 ++++++++ internal/portals/errors.go | 64 ++ internal/portals/export_test.go | 154 +++++ internal/portals/payload.go | 187 ++++++ internal/portals/portalstest/tenant.go | 23 +- internal/portals/portalstest/tenant_test.go | 14 + internal/portals/resources.go | 384 +++++++++++ internal/portals/resources_test.go | 407 ++++++++++++ internal/portals/setup.go | 610 ++++++++++++++++++ internal/portals/setup_test.go | 583 +++++++++++++++++ internal/portals/slug.go | 45 ++ internal/portals/slug_test.go | 68 ++ internal/portals/testdata/consumer-setup.json | 83 +++ 19 files changed, 3699 insertions(+), 2 deletions(-) create mode 100644 internal/portals/data.go create mode 100644 internal/portals/data/forms/communication_preferences.json create mode 100644 internal/portals/data/forms/personal_info.json create mode 100644 internal/portals/data/forms/privacy_consent.json create mode 100644 internal/portals/data/templates/business.json create mode 100644 internal/portals/data/templates/consumer.json create mode 100644 internal/portals/data_test.go create mode 100644 internal/portals/export_test.go create mode 100644 internal/portals/payload.go create mode 100644 internal/portals/resources.go create mode 100644 internal/portals/resources_test.go create mode 100644 internal/portals/setup.go create mode 100644 internal/portals/setup_test.go create mode 100644 internal/portals/slug.go create mode 100644 internal/portals/slug_test.go create mode 100644 internal/portals/testdata/consumer-setup.json 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 index 7d36d317e..a668567ce 100644 --- a/internal/portals/errors.go +++ b/internal/portals/errors.go @@ -164,3 +164,67 @@ func FixesOf(err error) []string { 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) +} + +// 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) +} diff --git a/internal/portals/export_test.go b/internal/portals/export_test.go new file mode 100644 index 000000000..382f6a201 --- /dev/null +++ b/internal/portals/export_test.go @@ -0,0 +1,154 @@ +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 + +// Template 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 +} 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/portalstest/tenant.go b/internal/portals/portalstest/tenant.go index 8c809d315..351b2a238 100644 --- a/internal/portals/portalstest/tenant.go +++ b/internal/portals/portalstest/tenant.go @@ -146,12 +146,26 @@ func (t *Tenant) Writes() []string { // 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 &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 &portals.APIError{Status: status, Message: "simulated failure"} @@ -1037,12 +1051,13 @@ func (t *Tenant) Probe(_ context.Context, method, path string, body any) error { defer t.mu.Unlock() key := method + " " + strings.SplitN(path, "?", 2)[0] - if err := t.begin(key); err != nil { + 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. + // 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 &portals.APIError{ Status: http.StatusBadRequest, @@ -1050,6 +1065,10 @@ func (t *Tenant) Probe(_ context.Context, method, path string, body any) error { } } + if err := t.takeFailure(key); err != nil { + return err + } + if method != http.MethodGet && strings.Contains(path, "preflight") { return notFound("The resource does not exist") } diff --git a/internal/portals/portalstest/tenant_test.go b/internal/portals/portalstest/tenant_test.go index 3469ef02c..1a9f78bbc 100644 --- a/internal/portals/portalstest/tenant_test.go +++ b/internal/portals/portalstest/tenant_test.go @@ -305,3 +305,17 @@ func TestReporter(t *testing.T) { 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/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/setup.go b/internal/portals/setup.go new file mode 100644 index 000000000..d3746853f --- /dev/null +++ b/internal/portals/setup.go @@ -0,0 +1,610 @@ +package portals + +import ( + "context" + "encoding/json" + "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) { + if !options.Template.IsValid() { + return SetupResult{}, &UsageError{ + Message: fmt.Sprintf("unknown template %q", options.Template), + Hints: []string{"Use --template consumer or --template business."}, + } + } + + if options.Template != TemplateConsumer { + return SetupResult{}, &UsageError{Message: "the business template is not available yet"} + } + + run := &setupRun{options: options, deps: deps, tenant: deps.TenantDomain} + + return run.execute(ctx) +} + +// 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 + } + + if !IsValidSlug(options.Slug) { + return SetupResult{}, &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."}, + } + } + + appName := portalName(options.Name) + + slug, err := r.availableSlug(ctx, options.Slug, appName) + if 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 + } + + return SetupResult{ + Template: options.Template, + Portal: PortalResult{ + ID: portal.value.ID, + Slug: portal.value.Slug, + URL: portalURL(host.domain, portal.value.Slug, ""), + 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, + }, 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 "", &UsageError{ + Message: fmt.Sprintf("The slug %q is already used by another portal. Rerun with a different --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.Status == "ready" && custom.Domain != "" { + 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 +} + +// JSON returns the result as indented JSON with a newline at the end, the way the command prints it. +func (r SetupResult) JSON() ([]byte, error) { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return nil, fmt.Errorf("encode the result of setup: %w", err) + } + + return append(data, '\n'), nil +} + +// 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 +} diff --git a/internal/portals/setup_test.go b/internal/portals/setup_test.go new file mode 100644 index 000000000..d03363312 --- /dev/null +++ b/internal/portals/setup_test.go @@ -0,0 +1,583 @@ +package portals_test + +import ( + "context" + "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 := result.JSON() + require.NoError(t, err) + + 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 usage *portals.UsageError + require.ErrorAs(t, err, &usage) + 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) + + require.Error(t, err) + assert.Contains(t, err.Error(), "already used by another portal") + }) + + 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")) +} + +func TestSetup_RejectsWhatItCannotRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + options portals.SetupOptions + want string + }{ + { + 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}$`, + }, + { + name: "a template nobody knows", + options: portals.SetupOptions{Template: "enterprise", Name: "Acme", Slug: "acme"}, + want: `unknown template "enterprise"`, + }, + } + + 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) + + var usage *portals.UsageError + require.ErrorAs(t, err, &usage) + assert.Equal(t, tt.want, err.Error()) + assert.Empty(t, tenant.Clients()) + assert.Empty(t, tenant.Portals()) + }) + } +} + +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) + }) +} 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/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" + } + ] +} From 371dc42f2b9c02b07d353db7b0a29ac7af7fa53e Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 10:18:09 +0100 Subject: [PATCH 07/27] feat(portals): set up a business portal A business portal opens in an organization, so the user who will test it has to belong to one, with a connection to sign in with and a role. Setup settles that before it creates anything, so a user who cannot sign in stops the run with nothing to clean up. The test user is given as an email or an ID, and an email keeps only the exact address. When several users match, the person picks one or looks for another. The organization is the only one of the user, the one they name or pick, or one they join or create after a confirmation, with a database connection enabled on it. A missing role is a warning that names the missing permissions, and the person can be offered a role of the tenant that has them, or the role of the portal, created with only the organization permissions the components use. Without a terminal the run stops with the exact command to fix what is missing. Skipping the test user asks for the organization of the link instead, and keeps a placeholder in it when none is given. Co-Authored-By: Claude --- internal/portals/business.go | 753 +++++++++++++++++++++++++++ internal/portals/business_test.go | 834 ++++++++++++++++++++++++++++++ internal/portals/errors.go | 13 + internal/portals/setup.go | 39 +- 4 files changed, 1633 insertions(+), 6 deletions(-) create mode 100644 internal/portals/business.go create mode 100644 internal/portals/business_test.go diff --git a/internal/portals/business.go b/internal/portals/business.go new file mode 100644 index 000000000..04b32cff0 --- /dev/null +++ b/internal/portals/business.go @@ -0,0 +1,753 @@ +package portals + +import ( + "context" + "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) + } + + var matches []Role + + for _, role := range tenantRoles { + granted, err := r.permissionsOf(ctx, role.ID) + if err != nil { + return nil, err + } + + if !slices.ContainsFunc(scopes, func(scope string) bool { return !slices.Contains(granted, 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..a647e4d5e --- /dev/null +++ b/internal/portals/business_test.go @@ -0,0 +1,834 @@ +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" +) + +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") + }) +} diff --git a/internal/portals/errors.go b/internal/portals/errors.go index a668567ce..30d911d2b 100644 --- a/internal/portals/errors.go +++ b/internal/portals/errors.go @@ -228,3 +228,16 @@ func (e *ClientSecretUnreadableError) Fixes() []string { 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/setup.go b/internal/portals/setup.go index d3746853f..b8a75404d 100644 --- a/internal/portals/setup.go +++ b/internal/portals/setup.go @@ -111,10 +111,6 @@ func Setup(ctx context.Context, options SetupOptions, deps SetupDeps) (SetupResu } } - if options.Template != TemplateConsumer { - return SetupResult{}, &UsageError{Message: "the business template is not available yet"} - } - run := &setupRun{options: options, deps: deps, tenant: deps.TenantDomain} return run.execute(ctx) @@ -162,6 +158,14 @@ func (r *setupRun) execute(ctx context.Context) (SetupResult, error) { } } + // A run that cannot ask has to be told the user up front. + if options.Template == TemplateBusiness && options.UserQuery == "" && !r.deps.Interactive { + return SetupResult{}, &BusinessAccessError{ + Message: "A Business portal needs the user who will test it", + Hints: []string{"Rerun with --user ."}, + } + } + appName := portalName(options.Name) slug, err := r.availableSlug(ctx, options.Slug, appName) @@ -169,6 +173,15 @@ func (r *setupRun) execute(ctx context.Context) (SetupResult, error) { 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 @@ -193,12 +206,16 @@ func (r *setupRun) execute(ctx context.Context) (SetupResult, error) { 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, ""), + URL: portalURL(host.domain, portal.value.Slug, link.organizationID), Status: portal.status, }, Application: ApplicationResult{ClientID: client.value.clientID, Status: client.status}, @@ -208,7 +225,8 @@ func (r *setupRun) execute(ctx context.Context) (SetupResult, error) { CommunicationPreferences: formResult(forms[FormCommunicationPreferences]), PrivacyConsent: formResult(forms[FormPrivacyConsent]), }, - Resources: r.resources, + Resources: r.resources, + Organization: link.result(), }, nil } @@ -608,3 +626,12 @@ func AskForMissing(options SetupOptions, prompter Prompter) (SetupOptions, error 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} +} From eeed332eb92be70b5ba940652d7df27f158410df Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 10:26:19 +0100 Subject: [PATCH 08/27] feat(cli): replace universal-portals setup The setup command now runs the setup of internal/portals: it asks for the kind of portal, its name and its slug, checks the permissions of the session, and creates the portal with its application, forms and pages, for a consumer or a business portal. --template, --name, --slug, --user and --organization give the answers, --no-input fails on what is missing, and --json prints the result. The old command, which created a portal application with a Management API grant and the client credentials grant, is replaced, and --name and --slug still work. Errors are classified as the other commands do. A missing scope is an auth error that lists how to get it for each way of signing in, and a failure after a write adds how to resume. The steps are printed after the message and listed in the details of the JSON error. The command takes the interrupt over for the time it runs, so a Ctrl+C says whether anything was created, and the helpers that only the old command used are removed. Co-Authored-By: Claude --- docs/auth0_universal-portals.md | 4 +- docs/auth0_universal-portals_setup.md | 20 +- docs/index.md | 2 +- internal/cli/universal_portals.go | 1130 +++-------------- internal/cli/universal_portals_errors.go | 110 ++ internal/cli/universal_portals_tenant.go | 17 + internal/cli/universal_portals_tenant_apps.go | 16 +- .../cli/universal_portals_tenant_apps_test.go | 100 ++ .../cli/universal_portals_tenant_directory.go | 14 +- internal/cli/universal_portals_tenant_raw.go | 6 +- internal/cli/universal_portals_test.go | 292 +++++ internal/cli/utils_shared.go | 46 - 12 files changed, 737 insertions(+), 1020 deletions(-) create mode 100644 internal/cli/universal_portals_errors.go create mode 100644 internal/cli/universal_portals_test.go diff --git a/docs/auth0_universal-portals.md b/docs/auth0_universal-portals.md index ab905bfbd..91ed1ae1a 100644 --- a/docs/auth0_universal-portals.md +++ b/docs/auth0_universal-portals.md @@ -5,9 +5,9 @@ has_children: true --- # auth0 universal-portals -Manage Auth0 Universal Portals resources. +Set up 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 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..d6557a882 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,20 @@ 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 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). + --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 +48,6 @@ 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 setup](auth0_universal-portals_setup.md) - Set up a Universal Portal diff --git a/docs/index.md b/docs/index.md index 6b931069d..40602b371 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 Universal Portals - [auth0 users](auth0_users.md) - Manage resources for users diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go index e0aca407f..74cb9779a 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -2,77 +2,59 @@ 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.", + } + 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 Universal Portals", + Long: "Set up Auth0 Universal Portals.", } cmd.SetUsageTemplate(namespaceUsageTemplate()) @@ -81,972 +63,232 @@ func universalPortalsCmd(cli *cli) *cobra.Command { 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: - - - 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) - }, - } - - upPortalName.RegisterString(cmd, &inputs.PortalName, "") - upPortalSlug.RegisterString(cmd, &inputs.Slug, "") - - return cmd +// universalPortalsSetupInputs are the flags of setup. +type universalPortalsSetupInputs struct { + Template string + Name string + Slug string + User string + Organization string } -// 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) - } - - // 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 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, ", "), - ) - - // 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) - } - - // 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) - } - } - } - } - - ctx := cmd.Context() - - // 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)) - } - 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") - } - - // 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") - } - - // The application name is derived automatically from the portal name. - appName := portalName + " (Universal Portals)" - - tenant := cli.tenant - - // 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 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)) +// options turns the flags into the options of setup, and checks them. +func (i universalPortalsSetupInputs) options() (portals.SetupOptions, error) { + template := portals.Template(i.Template) + if i.Template != "" && !template.IsValid() { + return portals.SetupOptions{}, usageError{ + err: fmt.Errorf("unknown template %q: use consumer or business", i.Template), + reason: "invalid_input", } } - // 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) - } - - 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() - - // 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) - } - } - 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() - - // 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() - - // 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) - } - } - cli.renderer.Successf("Portal %q created", portal.Name) - - portalURL := "https://" + domain + "/portals/" + portal.Slug - - cli.renderer.Newline() - cli.renderer.Infof("Portal: %s", ansi.Cyan(portalURL)) - cli.renderer.Newline() - - 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 { - return nil - } - - if err := browser.OpenURL(portalURL); err != nil { - cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s", portalURL) - } - - return nil + return portals.SetupOptions{ + Template: template, + Name: i.Name, + Slug: i.Slug, + UserQuery: i.User, + Organization: i.Organization, + }, nil } -// portalResourceServer describes a resource server that Universal Portals requires. -type portalResourceServer struct { - name string - identifier string -} +// missingForNoPrompt returns the flags a run that cannot ask has to be given. +func (i universalPortalsSetupInputs) missingForNoPrompt() []string { + var missing []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/"}, + if i.Template == "" { + missing = append(missing, "--template") } -} -// 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 + if i.Name == "" { + missing = append(missing, "--name") } - 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" - 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 + if i.Template == string(portals.TemplateBusiness) && i.User == "" { + missing = append(missing, "--user") } - return false, fmt.Errorf("failed to ensure resource server %q: %w", name, err) -} -type portalClientResult struct { - ClientID string - ClientSecret string + return missing } -// 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 +func universalPortalsSetupCmd(cli *cli) *cobra.Command { + var inputs universalPortalsSetupInputs + + 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. - 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}, - }, +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 up setup --no-input --template consumer --name "Acme Account" --json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runUniversalPortalsSetup(cmd, cli, inputs) }, } - 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, - }, - } - } + 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, "") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") - if err := api.Create(ctx, c); err != nil { - return portalClientResult{}, err - } - return portalClientResult{ - ClientID: c.GetClientID(), - ClientSecret: c.GetClientSecret(), - }, nil + return cmd } -// 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 - - 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}, - } +// portalsBackend is the tenant a command works on, and whether it changed it. +type portalsBackend interface { + portals.API + hasWritten() bool } -// 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 +// runUniversalPortalsSetup runs setup on the tenant of the CLI. +func runUniversalPortalsSetup(cmd *cobra.Command, cli *cli, inputs universalPortalsSetupInputs) error { + return runUniversalPortalsSetupOn(cmd, cli, inputs, newPortalsTenant(cli.api, cli.apiv3)) } -// 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 } - -// 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) +// 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, + tenant portalsBackend, +) error { + options, err := inputs.options() if err != nil { return err } - resp, err := h.Do(req) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode >= http.StatusBadRequest { - var apiErr struct { - StatusCode int `json:"statusCode"` - Message string `json:"message"` - } - _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if resp.StatusCode == http.StatusConflict { - return &errAPIConflict{message: apiErr.Message} + 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", } - 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"` -} - -// 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}, - }, } -} -// 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}}", - }, - }, - }, - }, - }, - }, + ctx, stop := portalsInterruptContext(cmd.Context()) + reporter := newPortalsTerminal( + cli.renderer.MessageWriter, + ansiPortalsStyle(), + cli.json || cli.renderer.AgentMode, + true, + ) + + result, err := executeUniversalPortalsSetup(ctx, universalPortalsSetup{ + options: options, + deps: portals.SetupDeps{ + API: tenant, + Reporter: reporter, + Prompter: portalsPrompter{}, + Interactive: interactive, + TenantDomain: cli.tenant, }, - "connections": map[string]any{ - "#CONN-1#": map[string]any{"id": connID}, - }, - } -} + }) -// 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}, - }, - } -} + interrupted := ctx.Err() != nil || errors.Is(err, portals.ErrCancelled) + stop() -// 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 + if interrupted { + reportPortalsCancelled(reporter, tenant.hasWritten()) + os.Exit(exitInterrupted) } - personalInfoID, err := importForm(buildPersonalInfoFormPayload(vaultConnID)) if err != nil { - return portalFormIDs{}, fmt.Errorf("failed to create personal info form: %w", err) + return portalsCommandError(err, tenant.hasWritten()) } - commPrefID, err := importForm(buildCommunicationPreferencesFormPayload(vaultConnID)) - if err != nil { - return portalFormIDs{}, fmt.Errorf("failed to create communication preferences form: %w", err) - } + if cli.json { + cli.renderer.JSONResult(result) - privacyID, err := importForm(buildPrivacySettingsFormPayload(vaultConnID)) - if err != nil { - return portalFormIDs{}, fmt.Errorf("failed to create privacy settings form: %w", err) + return nil } - 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"` -} + reporter.Ready(result.Portal.URL) -type portalClientRef struct { - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` -} + if interactive { + openPortal(cli, result.Portal.URL) + } -type portalNavigation struct { - Sidebar portalSidebar `json:"sidebar"` + return nil } -type portalSidebar struct { - Components []portalComponent `json:"components"` +// universalPortalsSetup is one run of setup. +type universalPortalsSetup struct { + options portals.SetupOptions + deps portals.SetupDeps } -type portalPages struct { - Default string `json:"default,omitempty"` - Standard []portalPage `json:"standard"` -} +// 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) -type portalPage struct { - Title string `json:"title"` - Slug string `json:"slug"` - Components []portalComponent `json:"components,omitempty"` -} + options := run.options -// 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"` -} - -// 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"` -} + if run.deps.Interactive { + var err error -// 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 + if options, err = portals.AskForMissing(options, run.deps.Prompter); err != nil { + return portals.SetupResult{}, err } } - slug := strings.TrimRight(b.String(), "-") - if slug == "" { - return "my-portal" + + if options.Slug == "" { + options.Slug = portals.SlugFromName(options.Name) } - return slug -} -// portalFormIDs holds the IDs of the three forms required by the portal. -type portalFormIDs struct { - PersonalInfo string - PrivacyConsent string - CommunicationPreferences string + return portals.Setup(ctx, options, run.deps) } -// 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}, - } +// 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 } - 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."), - ), - }, - }, - }, - }, + 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)) } } -// 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 +// 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 result, nil + + reporter.Cancelled("Setup cancelled.") } -// 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] +// 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) + + signal.Reset(os.Interrupt, syscall.SIGTERM) + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + + go func() { + select { + case <-signals: + cancel() + case <-ctx.Done(): + } + }() + + 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_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_tenant.go b/internal/cli/universal_portals_tenant.go index dcd5a395b..7f9619c15 100644 --- a/internal/cli/universal_portals_tenant.go +++ b/internal/cli/universal_portals_tenant.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "sync/atomic" "github.com/auth0/go-auth0/management" "github.com/auth0/go-auth0/v3/management/core" @@ -26,6 +27,8 @@ const ( 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) @@ -34,6 +37,20 @@ 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. diff --git a/internal/cli/universal_portals_tenant_apps.go b/internal/cli/universal_portals_tenant_apps.go index d5ace11c2..516a6aff7 100644 --- a/internal/cli/universal_portals_tenant_apps.go +++ b/internal/cli/universal_portals_tenant_apps.go @@ -48,7 +48,7 @@ func (t *portalsTenant) CreateResourceServer(ctx context.Context, spec portals.R server.TokenDialect = goauth0.String(spec.TokenDialect) } - if err := t.api.ResourceServer.Create(ctx, server); err != nil { + if err := t.written(t.api.ResourceServer.Create(ctx, server)); err != nil { return portals.ResourceServer{}, asPortalsError(err) } @@ -107,7 +107,7 @@ func (t *portalsTenant) ReadClientSecret(ctx context.Context, id string) (string // CreateClient implements portals.ClientStore. func (t *portalsTenant) CreateClient(ctx context.Context, settings portals.ClientSettings) (portals.Client, error) { client := clientToSDK(settings) - if err := t.api.Client.Create(ctx, client); err != nil { + if err := t.written(t.api.Client.Create(ctx, client)); err != nil { return portals.Client{}, asPortalsError(err) } @@ -116,7 +116,7 @@ func (t *portalsTenant) CreateClient(ctx context.Context, settings portals.Clien // UpdateClient implements portals.ClientStore. func (t *portalsTenant) UpdateClient(ctx context.Context, id string, settings portals.ClientSettings) error { - return asPortalsError(t.api.Client.Update(ctx, id, clientToSDK(settings))) + 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. @@ -324,7 +324,7 @@ func (t *portalsTenant) CreateClientGrant(ctx context.Context, spec portals.Clie } created, err := t.apiv3.ClientGrant.Create(ctx, request) - if err != nil { + if err := t.written(err); err != nil { return portals.ClientGrant{}, asPortalsError(err) } @@ -340,7 +340,7 @@ func (t *portalsTenant) CreateClientGrant(ctx context.Context, spec portals.Clie 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(err) + return asPortalsError(t.written(err)) } // ListVaultConnections implements portals.VaultStore. @@ -366,7 +366,7 @@ func (t *portalsTenant) CreateVaultConnection(ctx context.Context, name string, Setup: vaultSetupToSDK(setup), } - if err := t.api.FlowVaultConnection.CreateConnection(ctx, connection); err != nil { + if err := t.written(t.api.FlowVaultConnection.CreateConnection(ctx, connection)); err != nil { return portals.VaultConnection{}, asPortalsError(err) } @@ -375,9 +375,9 @@ func (t *portalsTenant) CreateVaultConnection(ctx context.Context, name string, // UpdateVaultConnection implements portals.VaultStore. func (t *portalsTenant) UpdateVaultConnection(ctx context.Context, id string, setup portals.VaultSetup) error { - return asPortalsError(t.api.FlowVaultConnection.UpdateConnection(ctx, id, &management.FlowVaultConnection{ + return asPortalsError(t.written(t.api.FlowVaultConnection.UpdateConnection(ctx, id, &management.FlowVaultConnection{ Setup: vaultSetupToSDK(setup), - })) + }))) } func vaultSetupToSDK(setup portals.VaultSetup) *map[string]interface{} { diff --git a/internal/cli/universal_portals_tenant_apps_test.go b/internal/cli/universal_portals_tenant_apps_test.go index 3abbec9ed..5b58450be 100644 --- a/internal/cli/universal_portals_tenant_apps_test.go +++ b/internal/cli/universal_portals_tenant_apps_test.go @@ -354,3 +354,103 @@ func TestPortalsTenant_Connections(t *testing.T) { 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 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.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 index 37f3559ef..cec3659c3 100644 --- a/internal/cli/universal_portals_tenant_directory.go +++ b/internal/cli/universal_portals_tenant_directory.go @@ -120,7 +120,7 @@ func (t *portalsTenant) CreateOrganization(ctx context.Context, spec portals.Org DisplayName: goauth0.String(spec.DisplayName), } - if err := t.api.Organization.Create(ctx, organization); err != nil { + if err := t.written(t.api.Organization.Create(ctx, organization)); err != nil { return portals.Organization{}, asPortalsError(err) } @@ -168,15 +168,15 @@ func (t *portalsTenant) ListOrganizationConnections(ctx context.Context, id stri // EnableOrganizationConnection implements portals.OrganizationStore. func (t *portalsTenant) EnableOrganizationConnection(ctx context.Context, id, connectionID string) error { - return asPortalsError(t.api.Organization.AddConnection(ctx, id, &management.OrganizationConnection{ + 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.api.Organization.AddMembers(ctx, id, []string{userID})) + return asPortalsError(t.written(t.api.Organization.AddMembers(ctx, id, []string{userID}))) } // ListMemberRoles implements portals.OrganizationStore. @@ -203,7 +203,7 @@ func (t *portalsTenant) ListMemberRoles(ctx context.Context, id, userID string) // AssignMemberRole implements portals.OrganizationStore. func (t *portalsTenant) AssignMemberRole(ctx context.Context, id, userID, roleID string) error { - return asPortalsError(t.api.Organization.AssignMemberRoles(ctx, id, userID, []string{roleID})) + return asPortalsError(t.written(t.api.Organization.AssignMemberRoles(ctx, id, userID, []string{roleID}))) } // ListRoles implements portals.RoleStore. @@ -235,7 +235,7 @@ func (t *portalsTenant) CreateRole(ctx context.Context, spec portals.RoleSpec) ( Description: goauth0.String(spec.Description), } - if err := t.api.Role.Create(ctx, role); err != nil { + if err := t.written(t.api.Role.Create(ctx, role)); err != nil { return portals.Role{}, asPortalsError(err) } @@ -277,5 +277,5 @@ func (t *portalsTenant) AddRolePermissions(ctx context.Context, roleID string, p }) } - return asPortalsError(t.api.Role.AssociatePermissions(ctx, roleID, sdkPermissions)) + return asPortalsError(t.written(t.api.Role.AssociatePermissions(ctx, roleID, sdkPermissions))) } diff --git a/internal/cli/universal_portals_tenant_raw.go b/internal/cli/universal_portals_tenant_raw.go index 6b5e58a31..59fb7c076 100644 --- a/internal/cli/universal_portals_tenant_raw.go +++ b/internal/cli/universal_portals_tenant_raw.go @@ -172,7 +172,7 @@ func (t *portalsTenant) ReadForm(ctx context.Context, id string) (portals.FormDe // 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 != nil { + if err := t.written(err); err != nil { return portals.ImportedForm{}, err } @@ -270,7 +270,7 @@ func (t *portalsTenant) ReadPortal(ctx context.Context, id string) (portals.Port // 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 != nil { + if err := t.written(err); err != nil { return portals.Portal{}, err } @@ -286,7 +286,7 @@ func (t *portalsTenant) CreatePortal(ctx context.Context, request portals.Create func (t *portalsTenant) UpdatePortal(ctx context.Context, id string, patch portals.PortalPatch) error { _, err := t.rawJSON(ctx, http.MethodPatch, "portals/"+id, patch) - return err + return t.written(err) } // Probe implements portals.Prober. diff --git a/internal/cli/universal_portals_test.go b/internal/cli/universal_portals_test.go new file mode 100644 index 000000000..d0e64ab28 --- /dev/null +++ b/internal/cli/universal_portals_test.go @@ -0,0 +1,292 @@ +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()) + + return runUniversalPortalsSetupOn(cmd, r.cli, inputs, writeTrackingTenant{Tenant: r.tenant}) +} + +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 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)) + }) +} + +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()) + }) + } +} 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 "" From d857ab156494e619cd72061a37bfb2194ed29408 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 10:36:03 +0100 Subject: [PATCH 09/27] feat(cli): add --dry-run to universal-portals setup A dry run plans the setup without writing to the tenant. Setup runs as it does for real, with the same questions, the same flag checks and the same decisions about what to create, reuse or update, on clients whose HTTP transport sends the reads to the tenant and keeps every other request. The kept requests are printed in the order a real run would send them, with every client secret replaced, and the resources a real run would create have placeholder IDs such as . With --json the output is the result of a real run plus dryRun and the requests, and otherwise a short summary. A dry run needs a login, and it opens no portal. The checks of the permissions still go to the tenant, because they are rejected and change nothing, so a dry run finds a missing scope that a real run would stop on. The Management API clients are now built by functions that take the HTTP client, which the dry run uses, and the CLI keeps the tenant and the token it built them with. Co-authored-by: Bharath Natarajan Co-Authored-By: Claude --- docs/auth0_universal-portals_setup.md | 2 + internal/cli/auth_env.go | 1 + internal/cli/cli.go | 12 + internal/cli/management.go | 15 +- internal/cli/universal_portals.go | 78 +++- internal/cli/universal_portals_dryrun.go | 283 +++++++++++++ internal/cli/universal_portals_dryrun_test.go | 389 ++++++++++++++++++ internal/cli/universal_portals_test.go | 61 ++- 8 files changed, 828 insertions(+), 13 deletions(-) create mode 100644 internal/cli/universal_portals_dryrun.go create mode 100644 internal/cli/universal_portals_dryrun_test.go diff --git a/docs/auth0_universal-portals_setup.md b/docs/auth0_universal-portals_setup.md index d6557a882..e0d4a11f1 100644 --- a/docs/auth0_universal-portals_setup.md +++ b/docs/auth0_universal-portals_setup.md @@ -19,6 +19,7 @@ auth0 universal-portals setup [flags] ``` 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 ``` @@ -26,6 +27,7 @@ auth0 universal-portals setup [flags] ## Flags ``` + --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. 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 74cb9779a..5a81e8829 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -40,6 +40,11 @@ var ( 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", @@ -70,6 +75,7 @@ type universalPortalsSetupInputs struct { Slug string User string Organization string + DryRun bool } // options turns the flags into the options of setup, and checks them. @@ -122,6 +128,7 @@ func universalPortalsSetupCmd(cli *cli) *cobra.Command { 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) @@ -133,20 +140,40 @@ Running the command again after a failure resumes: what exists is reused.`, 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.") return cmd } -// portalsBackend is the tenant a command works on, and whether it changed it. -type portalsBackend interface { - portals.API - hasWritten() bool +// 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 } -// runUniversalPortalsSetup runs setup on the tenant of the CLI. +// 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 { - return runUniversalPortalsSetupOn(cmd, cli, inputs, newPortalsTenant(cli.api, cli.apiv3)) + tenant := newPortalsTenant(cli.api, cli.apiv3) + target := portalsSetupTarget{api: tenant, hasWritten: tenant.hasWritten} + + if inputs.DryRun { + dryTenant, plan, err := dryRunTenant(cli.apiAccess) + if err != nil { + return 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} + } + + return runUniversalPortalsSetupOn(cmd, cli, inputs, target) } // runUniversalPortalsSetupOn runs setup. It takes over the interrupt from the root command for the @@ -155,7 +182,7 @@ func runUniversalPortalsSetupOn( cmd *cobra.Command, cli *cli, inputs universalPortalsSetupInputs, - tenant portalsBackend, + target portalsSetupTarget, ) error { options, err := inputs.options() if err != nil { @@ -180,8 +207,10 @@ func runUniversalPortalsSetupOn( result, err := executeUniversalPortalsSetup(ctx, universalPortalsSetup{ options: options, + dryRun: target.planned != nil, deps: portals.SetupDeps{ - API: tenant, + API: target.api, + Prober: target.prober, Reporter: reporter, Prompter: portalsPrompter{}, Interactive: interactive, @@ -193,12 +222,19 @@ func runUniversalPortalsSetupOn( stop() if interrupted { - reportPortalsCancelled(reporter, tenant.hasWritten()) + reportPortalsCancelled(reporter, target.hasWritten()) os.Exit(exitInterrupted) } if err != nil { - return portalsCommandError(err, tenant.hasWritten()) + return portalsCommandError(err, target.hasWritten()) + } + + // A dry run created nothing, so there is no portal to open. + if target.planned != nil { + reportPortalsPlan(cli, reporter, result, target.planned()) + + return nil } if cli.json { @@ -216,16 +252,38 @@ func runUniversalPortalsSetupOn( return nil } +// 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{} + } + + if cli.json { + cli.renderer.JSONResult(dryRunResult{DryRun: true, SetupResult: result, Requests: planned}) + + return + } + + 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.") +} + // universalPortalsSetup is one run of setup. type universalPortalsSetup struct { options portals.SetupOptions deps portals.SetupDeps + dryRun bool } // 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) + if run.dryRun { + run.deps.Reporter.Info("Dry run: nothing is created or changed. Statuses show what a real run would do.") + } + options := run.options if run.deps.Interactive { 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..658696064 --- /dev/null +++ b/internal/cli/universal_portals_dryrun_test.go @@ -0,0 +1,389 @@ +package cli + +import ( + "context" + "encoding/json" + "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, "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": []`) + }) +} diff --git a/internal/cli/universal_portals_test.go b/internal/cli/universal_portals_test.go index d0e64ab28..569d3baea 100644 --- a/internal/cli/universal_portals_test.go +++ b/internal/cli/universal_portals_test.go @@ -52,7 +52,9 @@ func (r *setupRun) run(inputs universalPortalsSetupInputs) error { cmd := &cobra.Command{} cmd.SetContext(context.Background()) - return runUniversalPortalsSetupOn(cmd, r.cli, inputs, writeTrackingTenant{Tenant: r.tenant}) + 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"} @@ -290,3 +292,60 @@ func TestReportPortalsCancelled(t *testing.T) { }) } } + +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)) + }) +} From 16a8c533718baa7d91aedf5d8f94e03bcbae5a5e Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 10:58:50 +0100 Subject: [PATCH 10/27] feat(portals): audit a portal and fix its errors The audit reads a portal and what it depends on at the same time: its application with the grants and the connections, the resource servers, whether each form exists, the custom domain and, when asked, an organization. It checks them against 24 rules, each an error that breaks the portal or a warning that is degraded or differs from the setup guide. A portal made by setup passes with no finding. The CLI fixes the errors it can: the home page, the shape of the sidebar, a missing application, the settings of the application and of its refresh token, the Management API grant, and the grants, resource servers and refresh token policies of the APIs the components call. Warnings are never changed. Every fix reads the portal again first and finds its finding by ID and subject, so a fix never works on a stale copy and is skipped when it is already in place. A fix can expose errors that the first audit could not see, so up to three rounds are made, and the errors a fix exposes are asked about again. --fix applies the errors without asking. With a terminal the person fixes all of them, chooses, or skips. A fix that fails is shown with its finding, the scopes the failed fixes needed are summed up once with how to get them, and a failed fix fails the audit even when the next audit no longer sees the error. The pages of a portal are sent back whole when one is fixed, so a page and a component keep the fields the package does not know. Co-authored-by: ramya18101 Co-Authored-By: Claude --- internal/cli/universal_portals_tenant_apps.go | 5 + .../cli/universal_portals_tenant_apps_test.go | 5 +- internal/cli/universal_portals_terminal.go | 9 + internal/portals/audit.go | 217 +++++ internal/portals/audit_checks.go | 685 ++++++++++++++++ internal/portals/audit_checks_test.go | 443 +++++++++++ internal/portals/audit_fix.go | 208 +++++ internal/portals/audit_fix_test.go | 742 ++++++++++++++++++ internal/portals/audit_remedies.go | 160 ++++ internal/portals/audit_snapshot.go | 304 +++++++ internal/portals/audit_test.go | 284 +++++++ internal/portals/export_test.go | 74 ++ internal/portals/helpers_test.go | 17 +- internal/portals/portal.go | 116 ++- internal/portals/portal_test.go | 82 ++ internal/portals/portalstest/seed.go | 37 + internal/portals/portalstest/tenant.go | 19 + internal/portals/portalstest/ui.go | 3 + internal/portals/store.go | 1 + internal/portals/ui.go | 2 + 20 files changed, 3404 insertions(+), 9 deletions(-) create mode 100644 internal/portals/audit.go create mode 100644 internal/portals/audit_checks.go create mode 100644 internal/portals/audit_checks_test.go create mode 100644 internal/portals/audit_fix.go create mode 100644 internal/portals/audit_fix_test.go create mode 100644 internal/portals/audit_remedies.go create mode 100644 internal/portals/audit_snapshot.go create mode 100644 internal/portals/audit_test.go create mode 100644 internal/portals/portal_test.go diff --git a/internal/cli/universal_portals_tenant_apps.go b/internal/cli/universal_portals_tenant_apps.go index 516a6aff7..74636d0e4 100644 --- a/internal/cli/universal_portals_tenant_apps.go +++ b/internal/cli/universal_portals_tenant_apps.go @@ -343,6 +343,11 @@ func (t *portalsTenant) UpdateClientGrantScope(ctx context.Context, id string, s 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)) diff --git a/internal/cli/universal_portals_tenant_apps_test.go b/internal/cli/universal_portals_tenant_apps_test.go index 5b58450be..0c31e7116 100644 --- a/internal/cli/universal_portals_tenant_apps_test.go +++ b/internal/cli/universal_portals_tenant_apps_test.go @@ -427,6 +427,9 @@ func TestPortalsTenant_TracksWhetherItChangedTheTenant(t *testing.T) { "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 @@ -440,7 +443,7 @@ func TestPortalsTenant_TracksWhetherItChangedTheTenant(t *testing.T) { 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.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"}, diff --git a/internal/cli/universal_portals_terminal.go b/internal/cli/universal_portals_terminal.go index 5b1a30d29..119237b84 100644 --- a/internal/cli/universal_portals_terminal.go +++ b/internal/cli/universal_portals_terminal.go @@ -278,6 +278,15 @@ func (t *portalsTerminal) Cancelled(message string) { t.println("") } +// Newline implements portals.Reporter. +func (t *portalsTerminal) Newline() { + if t.silent { + return + } + + t.message("") +} + // 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) { 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..80b2fd35b --- /dev/null +++ b/internal/portals/audit_checks.go @@ -0,0 +1,685 @@ +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 ready 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" }) + + 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{ + Summary: "Turn off the client_credentials grant", + apply: func(ctx context.Context, api API) error { + return updateApplication(ctx, api, clientID, ClientSettings{GrantTypes: remaining}) + }, + }, + }) + } + + 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..e9a774d08 --- /dev/null +++ b/internal/portals/audit_checks_test.go @@ -0,0 +1,443 @@ +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("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) +} + +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..1fd238f36 --- /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.Status == "ready", + }) + 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..4a0a2d10b --- /dev/null +++ b/internal/portals/audit_snapshot.go @@ -0,0 +1,304 @@ +package portals + +import ( + "context" + "fmt" + "net/http" + "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.Status == "ready" { + 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 != "" && !containsString(ids, id) { + ids = append(ids, id) + } + } + + return ids +} + +func containsString(values []string, value string) bool { + for _, candidate := range values { + if candidate == value { + return true + } + } + + return false +} + +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() (err error) { return result.readApplication(ctx, api) }, + func() (err error) { return result.readResourceServers(ctx, api) }, + func() (err error) { return result.readForms(ctx, api) }, + func() (err 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]() }) + + for _, failure := range failures { + if failure != nil { + return snapshot{}, failure + } + } + + 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) + } + }) + + for _, failure := range failures { + if failure != nil { + return fmt.Errorf("read the grants and connections of application %q: %w", clientID, failure) + } + } + + 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) + } + }) + + for _, failure := range failures { + if failure != nil { + return failure + } + } + + 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..127a9018f --- /dev/null +++ b/internal/portals/audit_test.go @@ -0,0 +1,284 @@ +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)) + }) +} + +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/export_test.go b/internal/portals/export_test.go index 382f6a201..fe9833961 100644 --- a/internal/portals/export_test.go +++ b/internal/portals/export_test.go @@ -152,3 +152,77 @@ func AudiencesFor(template Template, tenantDomain string) map[string][]string { 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 index ca1ea0965..0ec69626e 100644 --- a/internal/portals/helpers_test.go +++ b/internal/portals/helpers_test.go @@ -1,8 +1,23 @@ package portals_test -import "fmt" +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/portal.go b/internal/portals/portal.go index c8e60a64e..8b217f400 100644 --- a/internal/portals/portal.go +++ b/internal/portals/portal.go @@ -1,5 +1,7 @@ package portals +import "encoding/json" + // PortalSummary is a portal in the list of the tenant. type PortalSummary struct { ID string @@ -48,18 +50,118 @@ type Pages struct { Standard []Page `json:"standard,omitempty"` } -// Page is a page of a portal. +// 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 `json:"title"` - Slug string `json:"slug"` - Components []Component `json:"components,omitempty"` + 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. +// blocks in config.children. The fields the package does not know are kept. type Component struct { - Type string `json:"type"` - Config map[string]any `json:"config,omitempty"` + 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 { + for name, target := range targets { + if value, found := fields[name]; found { + if err := json.Unmarshal(value, target); err != nil { + return 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. diff --git a/internal/portals/portal_test.go b/internal/portals/portal_test.go new file mode 100644 index 000000000..ba3b0915f --- /dev/null +++ b/internal/portals/portal_test.go @@ -0,0 +1,82 @@ +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)) +} diff --git a/internal/portals/portalstest/seed.go b/internal/portals/portalstest/seed.go index 35aa90450..a9123d066 100644 --- a/internal/portals/portalstest/seed.go +++ b/internal/portals/portalstest/seed.go @@ -225,3 +225,40 @@ func (t *Tenant) RolePermissions(roleID string) []portals.Permission { 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 index 351b2a238..0ee999f04 100644 --- a/internal/portals/portalstest/tenant.go +++ b/internal/portals/portalstest/tenant.go @@ -477,6 +477,25 @@ func (t *Tenant) UpdateClientGrantScope(_ context.Context, id string, scope []st 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() diff --git a/internal/portals/portalstest/ui.go b/internal/portals/portalstest/ui.go index c0087c597..b18fd6191 100644 --- a/internal/portals/portalstest/ui.go +++ b/internal/portals/portalstest/ui.go @@ -152,6 +152,9 @@ 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, ",")) diff --git a/internal/portals/store.go b/internal/portals/store.go index c8eb034c7..dd19fb9af 100644 --- a/internal/portals/store.go +++ b/internal/portals/store.go @@ -32,6 +32,7 @@ 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. diff --git a/internal/portals/ui.go b/internal/portals/ui.go index 6b221b428..e58200324 100644 --- a/internal/portals/ui.go +++ b/internal/portals/ui.go @@ -59,6 +59,8 @@ type Reporter interface { 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. From 795c571d96ac15d4d0239f8da0c7c4e9b33f9f14 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 11:03:57 +0100 Subject: [PATCH 11/27] feat(cli): add universal-portals audit audit checks a Universal Portal and its application and shows what it found in a table, with the command to run for the findings the CLI cannot fix. It fails when there is an error, with --strict when there is a warning, and when a fix failed. With a terminal it offers to fix the errors, all of them or the ones that are chosen, and --fix fixes them without asking. --organization checks that the organization a business portal opens in shares a connection with the application, and --json prints the result. A failed request keeps the error of the client in it, so the CLI classifies the failure of an audit as it does the others: a portal that does not exist is not found, and a missing scope is an auth error with the steps to get it. Like setup, audit takes the interrupt over while it runs, so a Ctrl+C says that the fixes already applied stay in the tenant. Co-authored-by: ramya18101 Co-Authored-By: Claude --- docs/auth0_universal-portals.md | 3 +- docs/auth0_universal-portals_audit.md | 54 ++++ docs/auth0_universal-portals_setup.md | 1 + docs/index.md | 2 +- internal/cli/universal_portals.go | 9 +- internal/cli/universal_portals_audit.go | 154 +++++++++++ internal/cli/universal_portals_audit_test.go | 267 +++++++++++++++++++ internal/cli/universal_portals_tenant.go | 13 +- internal/portals/errors.go | 5 + internal/portals/portalstest/tenant.go | 31 ++- 10 files changed, 521 insertions(+), 18 deletions(-) create mode 100644 docs/auth0_universal-portals_audit.md create mode 100644 internal/cli/universal_portals_audit.go create mode 100644 internal/cli/universal_portals_audit_test.go diff --git a/docs/auth0_universal-portals.md b/docs/auth0_universal-portals.md index 91ed1ae1a..315a49f01 100644 --- a/docs/auth0_universal-portals.md +++ b/docs/auth0_universal-portals.md @@ -5,9 +5,10 @@ has_children: true --- # auth0 universal-portals -Set up Auth0 Universal Portals. +Set up and audit Auth0 Universal Portals. ## 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_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 e0d4a11f1..7afb7246d 100644 --- a/docs/auth0_universal-portals_setup.md +++ b/docs/auth0_universal-portals_setup.md @@ -50,6 +50,7 @@ auth0 universal-portals setup [flags] ## 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/index.md b/docs/index.md index 40602b371..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) - Set up Universal Portals +- [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/cli/universal_portals.go b/internal/cli/universal_portals.go index 5a81e8829..6a5633dc4 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -58,12 +58,15 @@ func universalPortalsCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "universal-portals", Aliases: []string{"up"}, - Short: "Set up Universal Portals", - Long: "Set up Auth0 Universal Portals.", + 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 } 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_tenant.go b/internal/cli/universal_portals_tenant.go index 7f9619c15..721af0b8f 100644 --- a/internal/cli/universal_portals_tenant.go +++ b/internal/cli/universal_portals_tenant.go @@ -67,13 +67,16 @@ func asPortalsError(err error) error { // 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} + 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(), ": ") - return apiErrorFromBody(v3.StatusCode, []byte(body)) + apiErr := apiErrorFromBody(v3.StatusCode, []byte(body)) + apiErr.Err = v3 + + return apiErr } return err @@ -81,7 +84,11 @@ func asPortalsError(err error) error { // 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))} + 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"` diff --git a/internal/portals/errors.go b/internal/portals/errors.go index 30d911d2b..1c0761cbb 100644 --- a/internal/portals/errors.go +++ b/internal/portals/errors.go @@ -16,8 +16,13 @@ type APIError struct { 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 != "": diff --git a/internal/portals/portalstest/tenant.go b/internal/portals/portalstest/tenant.go index 0ee999f04..383b233c4 100644 --- a/internal/portals/portalstest/tenant.go +++ b/internal/portals/portalstest/tenant.go @@ -8,6 +8,7 @@ package portalstest import ( "context" "encoding/json" + "errors" "fmt" "net/http" "regexp" @@ -15,6 +16,8 @@ import ( "strings" "sync" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/auth0-cli/internal/portals" ) @@ -158,7 +161,7 @@ func (t *Tenant) record(key string) error { t.calls = append(t.calls, key) if message, denied := t.denials[key]; denied { - return &portals.APIError{Status: http.StatusForbidden, Code: "insufficient_scope", Message: message} + return withCause(&portals.APIError{Status: http.StatusForbidden, Code: "insufficient_scope", Message: message}) } return nil @@ -168,7 +171,7 @@ func (t *Tenant) record(key string) error { func (t *Tenant) takeFailure(key string) error { if status, failing := t.failures[key]; failing { delete(t.failures, key) - return &portals.APIError{Status: status, Message: "simulated failure"} + return withCause(&portals.APIError{Status: status, Message: "simulated failure"}) } return nil @@ -185,12 +188,20 @@ func (t *Tenant) newID(kind string) string { 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 &portals.APIError{Status: http.StatusNotFound, Message: message} + return withCause(&portals.APIError{Status: http.StatusNotFound, Message: message}) } func conflict(message string) error { - return &portals.APIError{Status: http.StatusConflict, Message: message} + 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. @@ -604,10 +615,10 @@ func (t *Tenant) ReadForm(_ context.Context, id string) (portals.FormDetail, err } if !formIDPattern.MatchString(id) { - return portals.FormDetail{}, &portals.APIError{ + 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 }) @@ -693,11 +704,11 @@ func (t *Tenant) CreatePortal(_ context.Context, request portals.CreatePortalReq for _, portal := range t.portals { if portal.Slug == request.Slug { - return portals.Portal{}, &portals.APIError{ + return portals.Portal{}, withCause(&portals.APIError{ Status: http.StatusConflict, Code: "portal_conflict", Message: "Slug already in use", - } + }) } } @@ -1078,10 +1089,10 @@ func (t *Tenant) Probe(_ context.Context, method, path string, body any) error { // 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 &portals.APIError{ + return withCause(&portals.APIError{ Status: http.StatusBadRequest, Message: "Payload validation error: 'Missing required property'", - } + }) } if err := t.takeFailure(key); err != nil { From 5268dc5d317dcc97d04ce25eeca609f2dd72e4ba Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 11:05:35 +0100 Subject: [PATCH 12/27] test(portals): check the scopes cover every request Every request that setup, its checks, the audit and its fixes make needs a scope, and the advice for a missing scope asks for the scopes of the tool. A test runs a consumer and a business setup, the checks, and an audit that fixes a broken portal, and fails when a request needs a scope that the advice does not ask for, so a call added later cannot leave a person who followed the advice refused. The result of setup no longer has a method of its own to print JSON, because the command prints it like the other commands do. Co-Authored-By: Claude --- internal/portals/scopes_coverage_test.go | 119 +++++++++++++++++++++++ internal/portals/setup.go | 11 --- internal/portals/setup_test.go | 4 +- 3 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 internal/portals/scopes_coverage_test.go 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/setup.go b/internal/portals/setup.go index b8a75404d..ebfda1413 100644 --- a/internal/portals/setup.go +++ b/internal/portals/setup.go @@ -2,7 +2,6 @@ package portals import ( "context" - "encoding/json" "fmt" "net/http" "net/url" @@ -579,16 +578,6 @@ func portalURL(host, slug, organizationID string) string { return address + "?organization=" + value } -// JSON returns the result as indented JSON with a newline at the end, the way the command prints it. -func (r SetupResult) JSON() ([]byte, error) { - data, err := json.MarshalIndent(r, "", " ") - if err != nil { - return nil, fmt.Errorf("encode the result of setup: %w", err) - } - - return append(data, '\n'), nil -} - // 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) { diff --git a/internal/portals/setup_test.go b/internal/portals/setup_test.go index d03363312..57996cea8 100644 --- a/internal/portals/setup_test.go +++ b/internal/portals/setup_test.go @@ -2,6 +2,7 @@ package portals_test import ( "context" + "encoding/json" "net/http" "os" "strings" @@ -87,8 +88,9 @@ func TestSetup_TheResultIsTheJSONTheToolPrinted(t *testing.T) { result := runSetup(t, portalstest.New(), consumerOptions()) - got, err := result.JSON() + 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) From 382112567b0a07f6aa5b0d8fdc4b42db8a0f5733 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 11:07:52 +0100 Subject: [PATCH 13/27] fix(cli): draw a blank line as the bare rail The blank line after the table of the audit was drawn with two trailing spaces after the rail, where the tool this replaces draws the rail alone. Co-Authored-By: Claude --- internal/cli/universal_portals_terminal.go | 2 +- internal/cli/universal_portals_terminal_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/cli/universal_portals_terminal.go b/internal/cli/universal_portals_terminal.go index 119237b84..b6b155e35 100644 --- a/internal/cli/universal_portals_terminal.go +++ b/internal/cli/universal_portals_terminal.go @@ -284,7 +284,7 @@ func (t *portalsTerminal) Newline() { return } - t.message("") + t.println(t.rail()) } // Table implements portals.Reporter. The widths follow the longest cell, and the first column is a diff --git a/internal/cli/universal_portals_terminal_test.go b/internal/cli/universal_portals_terminal_test.go index 128a3eb3f..fc0bf97ed 100644 --- a/internal/cli/universal_portals_terminal_test.go +++ b/internal/cli/universal_portals_terminal_test.go @@ -262,3 +262,18 @@ func TestAnsiPortalsStyle_KeepsTheText(t *testing.T) { 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()) +} From 314716e862f25d69529d35f148b1fc3958663cec Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:16:35 +0100 Subject: [PATCH 14/27] fix(portals): report a taken slug as slug_taken The up-front slug check returned a plain UsageError, so a slug clash in a run that cannot ask was reported as invalid_input and the slug_taken branch of the CLI classification was never reached. Return SlugTakenError instead, with the hint to rerun with a different --slug. The error is now a validation error, like name_in_use and slug_race, so a JSON consumer can tell it from a malformed flag. Co-Authored-By: Claude --- internal/cli/universal_portals_test.go | 20 ++++++++++++++++++++ internal/portals/errors.go | 3 +++ internal/portals/setup.go | 4 +--- internal/portals/setup_test.go | 10 ++++++---- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/internal/cli/universal_portals_test.go b/internal/cli/universal_portals_test.go index 569d3baea..9afbc3a52 100644 --- a/internal/cli/universal_portals_test.go +++ b/internal/cli/universal_portals_test.go @@ -204,6 +204,26 @@ func TestSetupCommand_ErrorsAreClassified(t *testing.T) { 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() diff --git a/internal/portals/errors.go b/internal/portals/errors.go index 1c0761cbb..226e23321 100644 --- a/internal/portals/errors.go +++ b/internal/portals/errors.go @@ -204,6 +204,9 @@ 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 { diff --git a/internal/portals/setup.go b/internal/portals/setup.go index ebfda1413..15c6ee8b9 100644 --- a/internal/portals/setup.go +++ b/internal/portals/setup.go @@ -294,9 +294,7 @@ func (r *setupRun) availableSlug(ctx context.Context, slug, appName string) (str } if !r.deps.Interactive { - return "", &UsageError{ - Message: fmt.Sprintf("The slug %q is already used by another portal. Rerun with a different --slug", current), - } + return "", &SlugTakenError{Slug: current} } r.report().Warn(fmt.Sprintf("The slug %q is already taken.", current)) diff --git a/internal/portals/setup_test.go b/internal/portals/setup_test.go index 57996cea8..1b32a173e 100644 --- a/internal/portals/setup_test.go +++ b/internal/portals/setup_test.go @@ -226,8 +226,9 @@ func TestSetup_ATakenSlug(t *testing.T) { _, err := portals.Setup(context.Background(), consumerOptions(), deps) - var usage *portals.UsageError - require.ErrorAs(t, err, &usage) + 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()) @@ -248,8 +249,9 @@ func TestSetup_ATakenSlug(t *testing.T) { _, err := portals.Setup(context.Background(), consumerOptions(), deps) - require.Error(t, err) - assert.Contains(t, err.Error(), "already used by another portal") + 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) { From a717bbdd8ebb8246cd86dce2225cf57be0413cb7 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:18:25 +0100 Subject: [PATCH 15/27] fix(cli): keep a portal or form id as one path segment rawJSON cut its path on "?" and "/" before the SDK escaped it, so an id given on the command line, such as audit "up_x?y/../z", changed the route and added a query instead of reaching the API as one segment. Add rawJSONAt, which takes the route as segments, and use it for the three calls that carry an id: read portal, update portal and read form. Co-Authored-By: Claude --- internal/cli/universal_portals_tenant_raw.go | 28 +++++---- .../cli/universal_portals_tenant_raw_test.go | 59 +++++++++++++++++++ 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/internal/cli/universal_portals_tenant_raw.go b/internal/cli/universal_portals_tenant_raw.go index 59fb7c076..799b4b6b0 100644 --- a/internal/cli/universal_portals_tenant_raw.go +++ b/internal/cli/universal_portals_tenant_raw.go @@ -23,18 +23,26 @@ import ( 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. +// 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) { - ctx, cancel := context.WithTimeout(ctx, portalsRequestTimeout) - defer cancel() - route, query, hasQuery := strings.Cut(path, "?") - - uri := t.api.HTTPClient.URI(strings.Split(route, "/")...) if hasQuery { - uri += "?" + query + 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) @@ -156,7 +164,7 @@ func (f formNodes) flowID() string { // 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.rawJSON(ctx, http.MethodGet, "forms/"+id, nil) + data, err := t.rawJSONAt(ctx, http.MethodGet, []string{"forms", id}, "", nil) if err != nil { return portals.FormDetail{}, err } @@ -254,7 +262,7 @@ func (t *portalsTenant) ListPortals(ctx context.Context) ([]portals.PortalSummar // ReadPortal implements portals.PortalStore. func (t *portalsTenant) ReadPortal(ctx context.Context, id string) (portals.Portal, error) { - data, err := t.rawJSON(ctx, http.MethodGet, "portals/"+id, nil) + data, err := t.rawJSONAt(ctx, http.MethodGet, []string{"portals", id}, "", nil) if err != nil { return portals.Portal{}, err } @@ -284,7 +292,7 @@ func (t *portalsTenant) CreatePortal(ctx context.Context, request portals.Create // UpdatePortal implements portals.PortalStore. func (t *portalsTenant) UpdatePortal(ctx context.Context, id string, patch portals.PortalPatch) error { - _, err := t.rawJSON(ctx, http.MethodPatch, "portals/"+id, patch) + _, err := t.rawJSONAt(ctx, http.MethodPatch, []string{"portals", id}, "", patch) return t.written(err) } diff --git a/internal/cli/universal_portals_tenant_raw_test.go b/internal/cli/universal_portals_tenant_raw_test.go index 2517a1375..2c05c133d 100644 --- a/internal/cli/universal_portals_tenant_raw_test.go +++ b/internal/cli/universal_portals_tenant_raw_test.go @@ -247,6 +247,65 @@ func firstKey(config map[string]any, key string) string { 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() From f3796c6024a48b4f7a059bddc143cc9936c639bb Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:19:51 +0100 Subject: [PATCH 16/27] fix(portals): report every failed audit read The audit reads the application, resource servers, forms and custom domain in parallel, but kept only the first failure. A user with two broken reads had to fix one, rerun, and only then see the other. Join the failures so one run shows all of them. Co-Authored-By: Claude --- internal/portals/audit_snapshot.go | 19 +++++++------------ internal/portals/audit_test.go | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/internal/portals/audit_snapshot.go b/internal/portals/audit_snapshot.go index 4a0a2d10b..0b0c2ffdf 100644 --- a/internal/portals/audit_snapshot.go +++ b/internal/portals/audit_snapshot.go @@ -2,6 +2,7 @@ package portals import ( "context" + "errors" "fmt" "net/http" "strings" @@ -166,10 +167,8 @@ func readSnapshot(ctx context.Context, api API, portalID, tenantDomain, organiza failures := make([]error, len(reads)) forEachParallel(len(reads), func(index int) { failures[index] = reads[index]() }) - for _, failure := range failures { - if failure != nil { - return snapshot{}, failure - } + if err := errors.Join(failures...); err != nil { + return snapshot{}, err } return result, nil @@ -208,10 +207,8 @@ func (s *snapshot) readApplication(ctx context.Context, api API) error { } }) - for _, failure := range failures { - if failure != nil { - return fmt.Errorf("read the grants and connections of application %q: %w", clientID, failure) - } + if err := errors.Join(failures...); err != nil { + return fmt.Errorf("read the grants and connections of application %q: %w", clientID, err) } s.Application = &client @@ -251,10 +248,8 @@ func (s *snapshot) readForms(ctx context.Context, api API) error { } }) - for _, failure := range failures { - if failure != nil { - return failure - } + if err := errors.Join(failures...); err != nil { + return err } s.Forms = statuses diff --git a/internal/portals/audit_test.go b/internal/portals/audit_test.go index 127a9018f..5ec86dd7b 100644 --- a/internal/portals/audit_test.go +++ b/internal/portals/audit_test.go @@ -216,6 +216,22 @@ func TestAudit_WhatItReports(t *testing.T) { 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) { From 7cde049db57dd7eccd228a4432dee3fe3e2dd8d4 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:23:10 +0100 Subject: [PATCH 17/27] fix(portalstest): leave a setting alone when it is empty ClientSettings says that on an update the settings left empty or nil are not sent. The SDK adapter does that, but the in-memory tenant let an empty, non-nil list clear the current callbacks, logout URLs or grant types. A flow test could pass on a change the real API never receives. Treat an empty list as not set in the in-memory tenant, and add a contract test that runs the same cases against both it and the adapter, so the two cannot drift apart again. Co-Authored-By: Claude --- .../cli/universal_portals_contract_test.go | 120 ++++++++++++++++++ internal/portals/portalstest/tenant.go | 9 +- 2 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 internal/cli/universal_portals_contract_test.go 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/portals/portalstest/tenant.go b/internal/portals/portalstest/tenant.go index 383b233c4..c6d7b655d 100644 --- a/internal/portals/portalstest/tenant.go +++ b/internal/portals/portalstest/tenant.go @@ -354,7 +354,8 @@ func (t *Tenant) UpdateClient(_ context.Context, id string, settings portals.Cli return nil } -// mergeClientSettings copies the settings that are set over the current ones. +// 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 @@ -392,15 +393,15 @@ func mergeClientSettings(current *portals.ClientSettings, changes portals.Client current.RequireProofOfPossession = changes.RequireProofOfPossession } - if changes.Callbacks != nil { + if len(changes.Callbacks) > 0 { current.Callbacks = changes.Callbacks } - if changes.AllowedLogoutURLs != nil { + if len(changes.AllowedLogoutURLs) > 0 { current.AllowedLogoutURLs = changes.AllowedLogoutURLs } - if changes.GrantTypes != nil { + if len(changes.GrantTypes) > 0 { current.GrantTypes = changes.GrantTypes } From 35aad50eccfe8f46c808410a7eee8bda799cb065 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:23:11 +0100 Subject: [PATCH 18/27] fix(portals): fix a sole client_credentials grant by hand P010 removes client_credentials by sending the grants that remain. When it is the only grant nothing remains, and an update never sends an empty list, so the fix reported success and changed nothing. In that case offer the command to set the grants the portal signs in with, and do not offer the automatic fix. Co-Authored-By: Claude --- internal/portals/audit_checks.go | 22 ++++++++++++++++------ internal/portals/audit_checks_test.go | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/internal/portals/audit_checks.go b/internal/portals/audit_checks.go index 80b2fd35b..bd9f9f598 100644 --- a/internal/portals/audit_checks.go +++ b/internal/portals/audit_checks.go @@ -309,17 +309,27 @@ func (a *portalAudit) application() bool { 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{ - Summary: "Turn off the client_credentials grant", - apply: func(ctx context.Context, api API) error { - return updateApplication(ctx, api, clientID, ClientSettings{GrantTypes: remaining}) - }, - }, + Remedy: remedy, }) } diff --git a/internal/portals/audit_checks_test.go b/internal/portals/audit_checks_test.go index e9a774d08..ad75825da 100644 --- a/internal/portals/audit_checks_test.go +++ b/internal/portals/audit_checks_test.go @@ -421,6 +421,24 @@ func TestAudit_ManagementAPIGrantsAreOneFinding(t *testing.T) { 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() From fb4ce67d8abc7b8a0a5bb17172a267c3caea3796 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:24:10 +0100 Subject: [PATCH 19/27] test(cli): pin the reason of every portals error The class and reason of an error are what a script reads from the JSON envelope. business_access, slug_taken and permission_check_failed were not pinned by any test, so a change to the classification would pass. Check each error of the portals package against its class, reason and fix hints in one table. Co-Authored-By: Claude --- internal/cli/universal_portals_test.go | 88 ++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/internal/cli/universal_portals_test.go b/internal/cli/universal_portals_test.go index 9afbc3a52..fcd6f3b71 100644 --- a/internal/cli/universal_portals_test.go +++ b/internal/cli/universal_portals_test.go @@ -234,6 +234,94 @@ func TestSetupCommand_ErrorsAreClassified(t *testing.T) { }) } +// 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() From c00d9781ab8da754245baf45db983d7dd904d83a Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:31:46 +0100 Subject: [PATCH 20/27] fix(cli): read an empty answer as an empty list A dry run answers every read about something it would create, such as an organization, with an empty object. For the roles of a member, the connections of an organization and the permissions of a role the SDK returns no list at all for that answer, and the adapters dereferenced it, so a dry run of a business portal that has to create an organization crashed. Treat the missing list as an empty one in those three adapters, and check all the listings a dry run can ask about in one table. Co-Authored-By: Claude --- .../cli/universal_portals_tenant_directory.go | 15 +++++ ...universal_portals_tenant_directory_test.go | 63 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/internal/cli/universal_portals_tenant_directory.go b/internal/cli/universal_portals_tenant_directory.go index cec3659c3..b71b58f82 100644 --- a/internal/cli/universal_portals_tenant_directory.go +++ b/internal/cli/universal_portals_tenant_directory.go @@ -152,6 +152,11 @@ func (t *portalsTenant) ListOrganizationConnections(ctx context.Context, id stri 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 { @@ -187,6 +192,11 @@ func (t *portalsTenant) ListMemberRoles(ctx context.Context, id, userID string) 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 { @@ -250,6 +260,11 @@ func (t *portalsTenant) ListRolePermissions(ctx context.Context, roleID string) 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 { diff --git a/internal/cli/universal_portals_tenant_directory_test.go b/internal/cli/universal_portals_tenant_directory_test.go index 7f8cfe463..9d64ff9d7 100644 --- a/internal/cli/universal_portals_tenant_directory_test.go +++ b/internal/cli/universal_portals_tenant_directory_test.go @@ -205,3 +205,66 @@ func TestPortalsTenant_Roles(t *testing.T) { 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) + }) + } +} From 8458b335c2bb8862fc0d3887640639837cdcb7be Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:31:46 +0100 Subject: [PATCH 21/27] test(cli): dry-run a business portal end to end The dry-run transport has a special case for the connections of an organization that the plan creates, and only the consumer template was ever run through it, so the business flow was never exercised. Run a business setup that creates an organization, enables a connection and adds the user through the transport. Check the order of the planned writes and that none of them reaches the tenant. Co-Authored-By: Claude --- internal/cli/universal_portals_dryrun_test.go | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/internal/cli/universal_portals_dryrun_test.go b/internal/cli/universal_portals_dryrun_test.go index 658696064..674de833c 100644 --- a/internal/cli/universal_portals_dryrun_test.go +++ b/internal/cli/universal_portals_dryrun_test.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -387,3 +388,106 @@ func TestReportPortalsPlan(t *testing.T) { 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", + "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) + } +} From 3d04a7991e02206640ec733454c01ad3f1387423 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:35:09 +0100 Subject: [PATCH 22/27] fix(portals): probe the writes a rerun and a role make The check of the permissions exists so that setup does not stop half way for a permission it could have found at the start. Two writes had no probe: updating the application, which a rerun does, and giving a member of an organization a role, which a business run does. A session without update:clients passed the check and failed on the rerun. Add both probes, and a test that runs a first run, a rerun and a business run with a new organization and role, and requires a probe of the same shape for every write. The counts of the probes are no longer asserted, because they say nothing about what is checked. Co-Authored-By: Claude --- internal/cli/universal_portals_dryrun_test.go | 2 + internal/portals/probes.go | 13 +++- internal/portals/probes_test.go | 72 ++++++++++++++++++- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/internal/cli/universal_portals_dryrun_test.go b/internal/cli/universal_portals_dryrun_test.go index 674de833c..3d0a6508f 100644 --- a/internal/cli/universal_portals_dryrun_test.go +++ b/internal/cli/universal_portals_dryrun_test.go @@ -207,6 +207,7 @@ func newDryRunServer(t *testing.T) (*portalsServer, *httptest.Server, apiAccess) 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"}`) @@ -408,6 +409,7 @@ func TestPortalsDryRun_SetupOfABusinessPortalThatCreatesAnOrganization(t *testin 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"}`) diff --git a/internal/portals/probes.go b/internal/portals/probes.go index 406f9f9b7..b8211a6eb 100644 --- a/internal/portals/probes.go +++ b/internal/portals/probes.go @@ -19,10 +19,12 @@ type Probe struct { // 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 @@ -35,7 +37,8 @@ func SetupProbes(template Template) []Probe { {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 grants and the vault connection it finds. + // 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{}}, @@ -69,6 +72,14 @@ func SetupProbes(template Template) []Probe { 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", diff --git a/internal/portals/probes_test.go b/internal/portals/probes_test.go index 1c8e87d75..fb24abc75 100644 --- a/internal/portals/probes_test.go +++ b/internal/portals/probes_test.go @@ -2,6 +2,9 @@ package portals_test import ( "context" + "net/http" + "slices" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -29,7 +32,7 @@ func TestSetupProbes(t *testing.T) { t.Run("the consumer template checks what a rerun updates too", func(t *testing.T) { t.Parallel() - assert.Len(t, consumer, 14) + 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") @@ -38,11 +41,10 @@ func TestSetupProbes(t *testing.T) { t.Run("the business template also checks what the test user needs", func(t *testing.T) { t.Parallel() - assert.Len(t, business, 21) 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", "create role", "add role permissions", + "add organization member", "assign a role to a member", "create role", "add role permissions", }) }) @@ -77,6 +79,70 @@ func TestSetupProbes(t *testing.T) { }) } +// 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() From 91dd4a9103d5f7a9123cc443af2d6d267b69d048 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:36:43 +0100 Subject: [PATCH 23/27] perf(portals): read the permissions of the roles together Finding the roles that fix a missing-permission warning read the permissions of every role in the tenant one after the other, so a tenant with many roles waited for each request in turn. Send the reads with the same bounded parallelism the audit and the checks of the permissions use, and keep the order of the roles. Co-Authored-By: Claude --- internal/portals/business.go | 22 +++++++---- internal/portals/business_test.go | 61 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/internal/portals/business.go b/internal/portals/business.go index 04b32cff0..531683c6f 100644 --- a/internal/portals/business.go +++ b/internal/portals/business.go @@ -2,6 +2,7 @@ package portals import ( "context" + "errors" "fmt" "net/http" "regexp" @@ -736,15 +737,22 @@ func (r *setupRun) rolesGranting(ctx context.Context, scopes []string) ([]Role, return nil, fmt.Errorf("list the roles: %w", err) } - var matches []Role + // 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)) - for _, role := range tenantRoles { - granted, err := r.permissionsOf(ctx, role.ID) - if err != nil { - return nil, err - } + 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 - if !slices.ContainsFunc(scopes, func(scope string) bool { return !slices.Contains(granted, scope) }) { + for index, role := range tenantRoles { + if !slices.ContainsFunc(scopes, func(scope string) bool { return !slices.Contains(granted[index], scope) }) { matches = append(matches, role) } } diff --git a/internal/portals/business_test.go b/internal/portals/business_test.go index a647e4d5e..31f8c42f2 100644 --- a/internal/portals/business_test.go +++ b/internal/portals/business_test.go @@ -5,7 +5,10 @@ import ( "errors" "net/http" "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -832,3 +835,61 @@ func TestSetup_TheRoleOfTheTestUser(t *testing.T) { 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") + } +} From db21325756c42b530956f8a85614e87ed932d15c Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:40:08 +0100 Subject: [PATCH 24/27] refactor(portals): check the setup options in one place The rules for the options of setup were spread over the cobra command and Setup, with different messages and error types. Setup also checked the slug and the user of a business portal only after the check of the permissions, so a session that lacked a scope was told about a typo in the slug only after it had fixed the scope. A name made of spaces was accepted and would have created an application with an empty name. Setup now checks the template, the name, the slug and the user before it sends anything, and makes an empty slug from the name. The command only maps the flags to the options, and keeps the one rule that is about flags, the ones a run that cannot ask must be given. The messages and reasons of the errors are unchanged. Co-Authored-By: Claude --- internal/cli/universal_portals.go | 25 +++---------- internal/portals/setup.go | 60 +++++++++++++++++++++---------- internal/portals/setup_test.go | 39 +++++++++++++++++--- 3 files changed, 80 insertions(+), 44 deletions(-) diff --git a/internal/cli/universal_portals.go b/internal/cli/universal_portals.go index 6a5633dc4..69a5b369c 100644 --- a/internal/cli/universal_portals.go +++ b/internal/cli/universal_portals.go @@ -81,23 +81,15 @@ type universalPortalsSetupInputs struct { DryRun bool } -// options turns the flags into the options of setup, and checks them. -func (i universalPortalsSetupInputs) options() (portals.SetupOptions, error) { - template := portals.Template(i.Template) - if i.Template != "" && !template.IsValid() { - return portals.SetupOptions{}, usageError{ - err: fmt.Errorf("unknown template %q: use consumer or business", i.Template), - reason: "invalid_input", - } - } - +// options turns the flags into the options of setup. Setup checks them. +func (i universalPortalsSetupInputs) options() portals.SetupOptions { return portals.SetupOptions{ - Template: template, + Template: portals.Template(i.Template), Name: i.Name, Slug: i.Slug, UserQuery: i.User, Organization: i.Organization, - }, nil + } } // missingForNoPrompt returns the flags a run that cannot ask has to be given. @@ -187,10 +179,7 @@ func runUniversalPortalsSetupOn( inputs universalPortalsSetupInputs, target portalsSetupTarget, ) error { - options, err := inputs.options() - if err != nil { - return err - } + options := inputs.options() interactive := canPrompt(cmd) if missing := inputs.missingForNoPrompt(); !interactive && len(missing) > 0 { @@ -297,10 +286,6 @@ func executeUniversalPortalsSetup(ctx context.Context, run universalPortalsSetup } } - if options.Slug == "" { - options.Slug = portals.SlugFromName(options.Name) - } - return portals.Setup(ctx, options, run.deps) } diff --git a/internal/portals/setup.go b/internal/portals/setup.go index 15c6ee8b9..cb08a1135 100644 --- a/internal/portals/setup.go +++ b/internal/portals/setup.go @@ -103,16 +103,53 @@ type OrganizationResult struct { // 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 SetupResult{}, &UsageError{ - Message: fmt.Sprintf("unknown template %q", options.Template), + return options, &UsageError{ + Message: fmt.Sprintf("unknown template %q: use consumer or business", options.Template), Hints: []string{"Use --template consumer or --template business."}, } } - run := &setupRun{options: options, deps: deps, tenant: deps.TenantDomain} + if strings.TrimSpace(options.Name) == "" { + return options, &UsageError{ + Message: "A portal needs a name", + Hints: []string{"Use --name with the name of the portal."}, + } + } - return run.execute(ctx) + 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. @@ -150,21 +187,6 @@ func (r *setupRun) execute(ctx context.Context) (SetupResult, error) { return SetupResult{}, err } - if !IsValidSlug(options.Slug) { - return SetupResult{}, &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 == "" && !r.deps.Interactive { - return SetupResult{}, &BusinessAccessError{ - Message: "A Business portal needs the user who will test it", - Hints: []string{"Rerun with --user ."}, - } - } - appName := portalName(options.Name) slug, err := r.availableSlug(ctx, options.Slug, appName) diff --git a/internal/portals/setup_test.go b/internal/portals/setup_test.go index 1b32a173e..7fea19f77 100644 --- a/internal/portals/setup_test.go +++ b/internal/portals/setup_test.go @@ -460,6 +460,9 @@ func TestSetup_TheFormsVaultIsRefreshedWhenItsGrantGainsAScope(t *testing.T) { 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() @@ -467,16 +470,31 @@ func TestSetup_RejectsWhatItCannotRun(t *testing.T) { 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"`, + 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), }, } @@ -489,11 +507,9 @@ func TestSetup_RejectsWhatItCannotRun(t *testing.T) { _, err := portals.Setup(context.Background(), tt.options, deps) - var usage *portals.UsageError - require.ErrorAs(t, err, &usage) + require.ErrorAs(t, err, tt.wantErr) assert.Equal(t, tt.want, err.Error()) - assert.Empty(t, tenant.Clients()) - assert.Empty(t, tenant.Portals()) + assert.Empty(t, tenant.Calls(), "nothing is sent, not even a check of the permissions") }) } } @@ -585,3 +601,16 @@ func TestAskForMissing(t *testing.T) { 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) +} From dde896c207f85450871f783c25941f540b32bdc3 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:41:32 +0100 Subject: [PATCH 25/27] refactor(portals): decide in one place if a domain is ready Whether the custom domain serves the portal was decided three ways: setup needed it to be ready and to have a name, while the audit and its fixes only needed it to be ready, so a ready domain with no name would have made the audit expect a callback on "https:///". Add CustomDomain.IsReady and use it in all three. P008 only needs the tenant to have a custom domain, as its test says, so its message no longer claims the domain must be ready. Co-Authored-By: Claude --- internal/portals/audit_checks.go | 2 +- internal/portals/audit_checks_test.go | 20 ++++++++++++++++++++ internal/portals/audit_remedies.go | 2 +- internal/portals/audit_snapshot.go | 2 +- internal/portals/setup.go | 2 +- internal/portals/types.go | 3 +++ 6 files changed, 27 insertions(+), 4 deletions(-) diff --git a/internal/portals/audit_checks.go b/internal/portals/audit_checks.go index bd9f9f598..5bd16dec7 100644 --- a/internal/portals/audit_checks.go +++ b/internal/portals/audit_checks.go @@ -277,7 +277,7 @@ func (a *portalAudit) formsAndCustomCode() { ID: "P008", Severity: SeverityWarning, Check: "custom code", - Message: "Custom code is set, but the tenant has no ready custom domain, so it is never injected.", + 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"}, }) } diff --git a/internal/portals/audit_checks_test.go b/internal/portals/audit_checks_test.go index ad75825da..72e30a244 100644 --- a/internal/portals/audit_checks_test.go +++ b/internal/portals/audit_checks_test.go @@ -329,6 +329,26 @@ func TestAudit_TheRulesThatAreNotBrokenStayQuiet(t *testing.T) { 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() diff --git a/internal/portals/audit_remedies.go b/internal/portals/audit_remedies.go index 1fd238f36..551ab4763 100644 --- a/internal/portals/audit_remedies.go +++ b/internal/portals/audit_remedies.go @@ -78,7 +78,7 @@ func createPortalApplication(ctx context.Context, api API, current snapshot, use template: template, tenantDomain: current.TenantDomain, host: current.host(), - isCustomDomain: current.CustomDomain != nil && current.CustomDomain.Status == "ready", + isCustomDomain: current.CustomDomain != nil && current.CustomDomain.IsReady(), }) if err != nil { return err diff --git a/internal/portals/audit_snapshot.go b/internal/portals/audit_snapshot.go index 0b0c2ffdf..255edb2b4 100644 --- a/internal/portals/audit_snapshot.go +++ b/internal/portals/audit_snapshot.go @@ -42,7 +42,7 @@ type snapshotOrganization struct { // 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.Status == "ready" { + if s.CustomDomain != nil && s.CustomDomain.IsReady() { return s.CustomDomain.Domain } diff --git a/internal/portals/setup.go b/internal/portals/setup.go index cb08a1135..2b2059315 100644 --- a/internal/portals/setup.go +++ b/internal/portals/setup.go @@ -571,7 +571,7 @@ func resolvePortalHost(ctx context.Context, store CustomDomainStore, tenantDomai return portalHost{}, fmt.Errorf("read the custom domain: %w", err) } - if custom.Status == "ready" && custom.Domain != "" { + if custom.IsReady() { return portalHost{domain: custom.Domain, isCustom: true}, nil } diff --git a/internal/portals/types.go b/internal/portals/types.go index 3994bd9b6..f01c60c40 100644 --- a/internal/portals/types.go +++ b/internal/portals/types.go @@ -132,6 +132,9 @@ type CustomDomain struct { 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 From 8835fd0ed4b2d0ec7471b4ab1782ad6a79d168e4 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:43:19 +0100 Subject: [PATCH 26/27] fix(portals): name the field a portal cannot be read from A portal or a page the API returned in a shape the CLI does not read failed with the plain error of the JSON decoder, such as "cannot unmarshal number into Go value of type string", with no field. The fields were also read in map order, so which error came first changed from run to run. Read the fields in the order of their names and say which one failed. Co-Authored-By: Claude --- internal/portals/portal.go | 14 ++++++++++---- internal/portals/portal_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/internal/portals/portal.go b/internal/portals/portal.go index 8b217f400..914a4e384 100644 --- a/internal/portals/portal.go +++ b/internal/portals/portal.go @@ -1,6 +1,11 @@ package portals -import "encoding/json" +import ( + "encoding/json" + "fmt" + "maps" + "slices" +) // PortalSummary is a portal in the list of the tenant. type PortalSummary struct { @@ -138,10 +143,11 @@ func splitFields(data []byte, names ...string) (known, extra map[string]json.Raw } func decodeFields(fields map[string]json.RawMessage, targets map[string]any) error { - for name, target := range targets { + // 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, target); err != nil { - return err + if err := json.Unmarshal(value, targets[name]); err != nil { + return fmt.Errorf("read the %q of the portal: %w", name, err) } } } diff --git a/internal/portals/portal_test.go b/internal/portals/portal_test.go index ba3b0915f..a45743fe6 100644 --- a/internal/portals/portal_test.go +++ b/internal/portals/portal_test.go @@ -80,3 +80,29 @@ func TestPortal_APortalKeepsItsPagesWhenReadAndSent(t *testing.T) { 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) + }) + } +} From 2c8818efd1a19e3ec1ac57b65086048928d9b55c Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Fri, 6 Nov 2026 13:43:19 +0100 Subject: [PATCH 27/27] refactor(portals): use what the standard library has Replace containsString with slices.Contains, count pages with range over an int, and drop the named results of the snapshot reads that were never used. Fix the doc comment of the PortalTemplate alias. The test of forEachParallel waited for its calls with no limit, so a function that ran them one by one would have hung the suite. Fail the test after a timeout instead. Co-Authored-By: Claude --- internal/cli/universal_portals_tenant.go | 2 +- internal/cli/universal_portals_tenant_raw.go | 2 +- internal/portals/audit_snapshot.go | 21 ++++++-------------- internal/portals/export_test.go | 2 +- internal/portals/parallel_test.go | 18 +++++++++++++++-- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/internal/cli/universal_portals_tenant.go b/internal/cli/universal_portals_tenant.go index 721af0b8f..f496512a2 100644 --- a/internal/cli/universal_portals_tenant.go +++ b/internal/cli/universal_portals_tenant.go @@ -110,7 +110,7 @@ func listPages[T any]( ) ([]T, error) { var all []T - for page := 0; page < portalsMaxPages; page++ { + for page := range portalsMaxPages { if err := ctx.Err(); err != nil { return nil, err } diff --git a/internal/cli/universal_portals_tenant_raw.go b/internal/cli/universal_portals_tenant_raw.go index 799b4b6b0..a820cb580 100644 --- a/internal/cli/universal_portals_tenant_raw.go +++ b/internal/cli/universal_portals_tenant_raw.go @@ -74,7 +74,7 @@ func (t *portalsTenant) rawJSONAt(ctx context.Context, method string, segments [ func rawList[T any](ctx context.Context, t *portalsTenant, route, key string) ([]T, error) { var all []T - for page := 0; page < portalsMaxPages; page++ { + for page := range portalsMaxPages { if err := ctx.Err(); err != nil { return nil, err } diff --git a/internal/portals/audit_snapshot.go b/internal/portals/audit_snapshot.go index 255edb2b4..f6ad1d63e 100644 --- a/internal/portals/audit_snapshot.go +++ b/internal/portals/audit_snapshot.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strings" ) @@ -122,7 +123,7 @@ func formIDsOf(portal Portal) []string { continue } - if id, _ := component.Config["form_id"].(string); id != "" && !containsString(ids, id) { + if id, _ := component.Config["form_id"].(string); id != "" && !slices.Contains(ids, id) { ids = append(ids, id) } } @@ -130,16 +131,6 @@ func formIDsOf(portal Portal) []string { return ids } -func containsString(values []string, value string) bool { - for _, candidate := range values { - if candidate == value { - return true - } - } - - return false -} - const formComponentType = "page:component:auth0:form" // readSnapshot reads the portal, and then what the audit needs to know about it. The reads do not @@ -153,10 +144,10 @@ func readSnapshot(ctx context.Context, api API, portalID, tenantDomain, organiza result := snapshot{TenantDomain: tenantDomain, Portal: portal} reads := []func() error{ - func() (err error) { return result.readApplication(ctx, api) }, - func() (err error) { return result.readResourceServers(ctx, api) }, - func() (err error) { return result.readForms(ctx, api) }, - func() (err error) { return result.readCustomDomain(ctx, api) }, + 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 != "" { diff --git a/internal/portals/export_test.go b/internal/portals/export_test.go index fe9833961..d05fec1a6 100644 --- a/internal/portals/export_test.go +++ b/internal/portals/export_test.go @@ -119,7 +119,7 @@ func BuildFormDocuments(vaultConnectionID string) (map[FormKey]map[string]any, e // FormIDs are the IDs of the forms a template uses. type FormIDs = formIDs -// Template is the pages of a kind of portal. +// PortalTemplate is the pages of a kind of portal. type PortalTemplate = portalTemplate func TemplateFor(template Template, forms FormIDs) (PortalTemplate, error) { diff --git a/internal/portals/parallel_test.go b/internal/portals/parallel_test.go index 358f040b8..cb411fcce 100644 --- a/internal/portals/parallel_test.go +++ b/internal/portals/parallel_test.go @@ -4,6 +4,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -36,8 +37,21 @@ func TestForEachParallel(t *testing.T) { started.Add(maxParallel) go func() { - // The first calls wait here until the limit is reached, which proves they run together. - started.Wait() + // 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) }()