Repository navigation
feat(cli): port universal-portals setup and audit to Go (Beta) - #1756
Open
jacobovidal wants to merge 27 commits into
Open
jacobovidal wants to merge 27 commits into
jacobovidal wants to merge 27 commits into
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <anusriankisetty@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <new:clients:2>. 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 <bharath.lalgudinatarajan@okta.com> Co-Authored-By: Claude <noreply@anthropic.com>
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 <anusriankisetty@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com>
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 <anusriankisetty@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This was referenced Nov 6, 2026
jacobovidal
marked this pull request as ready for review
November 6, 2026 11:50
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔧 Changes
Replaces the basic
auth0 universal-portals setupwith a full native Go implementation, and addsauth0 universal-portals audit. Nothing here needs Node or an external tool: everything runs inside the CLI on top of the existinggo-auth0clients.go.modandgo.sumare unchanged.setupconsumer(end users manage their account) andbusiness(organization administrators manage their organization). Interactive by default;--no-inputplus flags for scripts.consumer, 23 forbusiness).--dry-runruns the whole flow against the tenant's real state, keeps the writes instead of sending them (placeholder ids, secrets redacted) and prints them.--jsonoutput is the same content and key order as the previous tool's, with 4-space indentation.audit <portal-id>--strict.--fixto apply every fix the CLI can make. Warnings are never changed because they may be intentional.--organizationalso checks that the portal and its application share a connection with the organization.Options are checked before anything is sent.
Setupchecks the template, the name, the slug and, for a business portal that cannot ask, the user, before the permission check. An empty slug is made from the name, and a name made of spaces is refused. The cobra command only maps flags to options, and keeps the one rule that is about flags: the flags a run that cannot ask must be given.Behavior change (beta only). The previous
setupcreated three resources from--nameand--slug. The new one keeps both flags (-n,-s) and does a superset of the work, but it also asks for--templatewhen interactive and fails with a usage error under--no-inputwithout it. Exit codes follow the CLI: 1 for errors including usage errors, 130 on interrupt.Structure
internal/portalsis the domain package: the portal model, 12 small store interfaces, setup, business setup, audit, the rules and fixes, the terminal-agnosticReporter/Prompterinterfaces, and an in-memory fake tenant (portalstest). It imports no cobra, renderer or SDK. Precedent for a domain package:internal/openapi,internal/auth.internal/cli/universal_portals_*.gois the thin layer: cobra commands, the survey prompter, the terminal renderer, the error mapping, the dry-run transport, and the tenant adapters that implement the stores overcli.apiandcli.apiv3. Typed SDK calls are used wherever one exists; raw requests only for portals, form import and read, and the permission probes.survey, the spinner andinternal/ansialready in the module.Shared files touched (outside the new command):
internal/cli/management.go: the client builders accept an HTTP client, so the dry-run transport can wrap them.internal/cli/cli.go,internal/cli/auth_env.go: keep the domain, token and metadata needed by the portals adapters.internal/cli/utils_shared.go: removes three helpers only the oldsetupused.internal/auth0: new wrappers (UserAPI.Organizations, organization add and assign,ClientConnectionAPIV3) and regenerated mocks.docs/: regenerated withmake docs.Known difference between setup and audit. On a rerun, setup resets the callback and logout URLs of the application to its own list, while audit P012 only adds the callback that is missing. Both are pinned by tests and left as they are. If a person adds a URL by hand, a rerun of setup removes it. Say if you want setup to keep extra URLs, with the catch that the old tenant-domain callback then stays after a custom domain goes live.
Deviations from the previous tool, all deliberate: a slug that another portal uses is reported as a
validationerror with the reasonslug_taken; P010 on an application whose only grant isclient_credentialsprints the command to run, because the CLI has no grant to leave it with; P008 says the tenant has "no custom domain", which is what it checks; usage errors exit 1 (CLI convention); agent mode without--jsonnow emits JSON; client-credentials scope advice with no named scope lists every required scope; a failedauditalso prints "The audit failed: N error(s)." from the root error handler, which is how the CLI exits 1.📚 References
--dry-rundesign comes from work by Bharath Natarajan, and the permission and scope checks from work by ramya18101. Both are credited withCo-authored-bytrailers on the commits that carry their code.🔬 Testing
Unit tests run with no credentials and no network:
go test ./... -count=1andmake lintboth pass.internal/portalsinternal/portals/portalstestinternal/cli--jsonoutput; the terminal renderer.A contract test runs the same "a setting left empty is not sent" cases against the in-memory tenant and the adapter over the SDK, so the fake the flow tests run on cannot drift from the requests the API receives. A test requires a probe for every write that a first run, a rerun and a business run with a new organization and role make. The business dry run is tested end to end through the transport.
Because the go-auth0 client mutates a package-level map when it is built, tests that build CLI clients do not run in parallel.
Run against a real tenant, twice: once on the first version of this branch, and again after the review fixes (the last commits). It was a throwaway test tenant with a ready custom domain. Everything created was deleted afterwards and each delete was checked with a 404, and a before/after snapshot of applications, resource servers, grants, vault connections, forms, portals, organizations, roles and flows matched both times.
On the first version:
--dry-runforconsumerwrites nothing.setupforconsumerandbusinesscreated the application, grants and portal, reused the existing resource servers, forms and (for business) the vault; a rerun reused everything with the same ids.auditof the new portals reported no findings. After breaking the application and sidebar by hand (refresh token rotation on,client_credentialsadded, a legacy sidebar link),auditreported the three errors and two warnings,audit --fixcleared them, and a finalauditwas clean.create:organization_member_roles. That scope is not in the required-scope list, which was carried over from the previous tool. Left as is; say if you want it added.After the review fixes, the second run covered what changed:
slug_takenafter the permission check, so the live API accepted the newupdate clientprobe.--dry-runforconsumerand forbusinesspassed the permission check, including the business-only probes, and wrote nothing. The only value of a secret field in the output is the placeholder.setupand a rerun forconsumercreated and then reused the same resources.audit --fixcleared the findings. Withclient_credentialsas the only grant,auditprinted a command instead of offering a fix,--fixleft the grant alone, and running the printed command restored the application and a clean audit.Not tested
📝 Checklist
Diagrams
Tan is the cobra layer, blue is the domain package, purple is the Management API side, gray is test support.
%%{init: { 'theme': 'base', 'themeVariables': { 'primaryColor': '#eef6f7', 'primaryTextColor': '#2f3a3d', 'primaryBorderColor': '#b8d8dd', 'lineColor': '#7f9ea6', 'background': '#ffffff' } } }%% flowchart LR subgraph CLI["internal/cli"] Setup["setup command"] Audit["audit command"] Adapters["tenant adapters"] DryRun["dry-run transport"] Terminal["terminal and prompter"] end subgraph Domain["internal/portals"] Run["setup and business setup"] Rules["24 audit rules and fixes"] Stores["12 store interfaces"] UI["Reporter and Prompter interfaces"] end subgraph Remote["Management API"] SDK["go-auth0 v1 and v3"] Raw["raw requests: portals, forms, probes"] end subgraph Support["test support"] Fake[("portalstest fake tenant")] end Setup --> Run Audit --> Rules Run --> Stores Rules --> Stores Run --> UI Terminal -.implements.-> UI Adapters -.implements.-> Stores Adapters --> SDK Adapters --> Raw DryRun -.wraps.-> SDK DryRun -.wraps.-> Raw Fake -.implements.-> Stores classDef entry fill:#f7efe5,stroke:#d8b98f,color:#2f3a3d classDef domain fill:#e8f4f8,stroke:#a8d4e6,color:#2f3a3d classDef remote fill:#f6eff9,stroke:#cfb5dc,color:#2f3a3d classDef support fill:#f3f3f3,stroke:#cfcfcf,color:#2f3a3d class Setup,Audit,Adapters,DryRun,Terminal entry class Run,Rules,Stores,UI domain class SDK,Raw remote class Fake support style CLI fill:#fdfaf5,stroke:#d8b98f style Domain fill:#f4fafc,stroke:#a8d4e6 style Remote fill:#fbf8fc,stroke:#cfb5dc style Support fill:#fafafa,stroke:#cfcfcfThe domain package depends only on interfaces. The same flow runs against the real tenant, the dry-run transport, or the fake tenant, depending on what is plugged into the stores.
%%{init: { 'sequence': {'mirrorActors':false}, 'theme': 'base', 'themeVariables': { 'primaryColor': '#eef6f7', 'primaryTextColor': '#2f3a3d', 'primaryBorderColor': '#b8d8dd', 'lineColor': '#7f9ea6', 'background': '#ffffff', 'actorBkg': '#f7efe5', 'actorBorder': '#d8b98f', 'actorTextColor': '#2f3a3d', 'noteBkgColor': '#fff8d8', 'noteTextColor': '#2f3a3d', 'noteBorderColor': '#e7d28a' } } }%% sequenceDiagram actor User box rgb(247,239,229) CLI layer participant Cmd as setup command participant Ad as tenant adapters end box rgb(232,244,248) Domain participant Pf as preflight participant Su as setup end box rgb(246,239,249) Management API participant API as tenant end User->>Cmd: setup --template consumer --name ... Cmd->>Su: run(stores, reporter, prompter) Su->>Pf: check permissions Pf->>Ad: probes, 4 at a time Ad->>API: requests that are rejected or empty API-->>Pf: 400 ok or 403 missing scope Pf-->>Su: ready, or MissingScopes with advice Su->>Ad: find or create app, grants, vault, forms, portal Ad->>API: GET and POST Su-->>Cmd: resources with created or reused status Cmd-->>User: summary, or the JSON resultWith
--dry-runthe adapters are built on the recording transport: the GETs above go to the tenant, and the POSTs are kept with placeholder ids instead of sent.🤖 Generated with Claude Code